@jhits/plugin-images 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhits/plugin-images",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Image management and storage plugin for the JHITS ecosystem",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
8
|
-
"main": "./
|
|
9
|
-
"types": "./
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": {
|
|
12
|
-
"types": "./
|
|
13
|
-
"default": "./
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
14
|
},
|
|
15
15
|
"./server": {
|
|
16
|
-
"types": "./
|
|
17
|
-
"default": "./
|
|
16
|
+
"types": "./dist/index.server.d.ts",
|
|
17
|
+
"default": "./dist/index.server.js"
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"@jhits/plugin-core": "0.0.
|
|
21
|
+
"lucide-react": "^0.564.0",
|
|
22
|
+
"mongodb": "^7.1.0",
|
|
23
|
+
"@jhits/plugin-core": "0.0.3"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"next": ">=15.0.0",
|
|
@@ -28,20 +28,24 @@
|
|
|
28
28
|
"react-dom": ">=18.0.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@tailwindcss/postcss": "^4",
|
|
32
|
-
"@types/node": "^
|
|
33
|
-
"@types/react": "^19",
|
|
34
|
-
"@types/react-dom": "^19",
|
|
35
|
-
"eslint": "^
|
|
36
|
-
"eslint-config-next": "16.1.
|
|
37
|
-
"next": "16.1.
|
|
38
|
-
"react": "19.2.
|
|
39
|
-
"react-dom": "19.2.
|
|
40
|
-
"tailwindcss": "^4",
|
|
41
|
-
"typescript": "^5"
|
|
31
|
+
"@tailwindcss/postcss": "^4.1.18",
|
|
32
|
+
"@types/node": "^25.2.3",
|
|
33
|
+
"@types/react": "^19.2.14",
|
|
34
|
+
"@types/react-dom": "^19.2.3",
|
|
35
|
+
"eslint": "^10.0.0",
|
|
36
|
+
"eslint-config-next": "16.1.6",
|
|
37
|
+
"next": "16.1.6",
|
|
38
|
+
"react": "19.2.4",
|
|
39
|
+
"react-dom": "19.2.4",
|
|
40
|
+
"tailwindcss": "^4.1.18",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
42
42
|
},
|
|
43
43
|
"files": [
|
|
44
|
+
"dist",
|
|
44
45
|
"src",
|
|
45
46
|
"package.json"
|
|
46
|
-
]
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc"
|
|
50
|
+
}
|
|
47
51
|
}
|
|
@@ -236,10 +236,10 @@ export function GlobalImageEditor() {
|
|
|
236
236
|
if (!isOpen || !selectedImage) {
|
|
237
237
|
return null;
|
|
238
238
|
}
|
|
239
|
-
|
|
239
|
+
|
|
240
240
|
return (
|
|
241
241
|
<div
|
|
242
|
-
className={`fixed inset-0 z-
|
|
242
|
+
className={`fixed inset-0 z-50 flex items-center justify-center ${config.overlayClassName || 'bg-black/50 backdrop-blur-sm'}`}
|
|
243
243
|
data-image-editor="true"
|
|
244
244
|
onClick={(e) => {
|
|
245
245
|
if (e.target === e.currentTarget) {
|
|
@@ -289,10 +289,22 @@ export function ImageBrowserModal({
|
|
|
289
289
|
const [resolvedSelectedId, setResolvedSelectedId] = useState<string | undefined>(selectedImageId);
|
|
290
290
|
const [internalUploading, setInternalUploading] = useState(false);
|
|
291
291
|
const [mounted, setMounted] = useState(false);
|
|
292
|
+
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
|
|
292
293
|
|
|
293
|
-
// Handle SSR - ensure we only render portal on client
|
|
294
|
+
// Handle SSR & portal target - ensure we only render portal on client
|
|
294
295
|
useEffect(() => {
|
|
295
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
|
+
}
|
|
296
308
|
}, []);
|
|
297
309
|
|
|
298
310
|
// Debug: Log when selectedImageId prop changes
|
|
@@ -650,10 +662,10 @@ export function ImageBrowserModal({
|
|
|
650
662
|
onClose();
|
|
651
663
|
};
|
|
652
664
|
|
|
653
|
-
if (!isOpen || !mounted) return null;
|
|
665
|
+
if (!isOpen || !mounted || !portalTarget) return null;
|
|
654
666
|
|
|
655
667
|
const modalContent = (
|
|
656
|
-
<div className="fixed inset-0 z-[
|
|
668
|
+
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 animate-in fade-in duration-200">
|
|
657
669
|
{/* Backdrop */}
|
|
658
670
|
<div
|
|
659
671
|
className="absolute inset-0 bg-neutral-950/70 backdrop-blur-md"
|
|
@@ -677,8 +689,9 @@ export function ImageBrowserModal({
|
|
|
677
689
|
<button
|
|
678
690
|
onClick={onClose}
|
|
679
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"
|
|
680
693
|
>
|
|
681
|
-
<X size={20} className="text-neutral-
|
|
694
|
+
<X size={20} className="text-neutral-500 dark:text-neutral-300 hover:text-neutral-700 dark:hover:text-white transition-colors" />
|
|
682
695
|
</button>
|
|
683
696
|
</div>
|
|
684
697
|
|
|
@@ -820,5 +833,5 @@ export function ImageBrowserModal({
|
|
|
820
833
|
</div>
|
|
821
834
|
);
|
|
822
835
|
|
|
823
|
-
return createPortal(modalContent,
|
|
836
|
+
return createPortal(modalContent, portalTarget);
|
|
824
837
|
}
|
|
@@ -9,6 +9,7 @@ import { ImageEditor, type ImageEditorHandle } from './ImageEditor';
|
|
|
9
9
|
import { ImageBrowserModal } from './ImageBrowserModal';
|
|
10
10
|
import { useImagePicker } from '../hooks/useImagePicker';
|
|
11
11
|
import { getImageTransform, getImageFilter } from '../utils/transforms';
|
|
12
|
+
import { getFallbackImageUrl } from '../utils/fallback';
|
|
12
13
|
|
|
13
14
|
export function ImagePicker({
|
|
14
15
|
value, onChange, brightness = 100, blur = 0, scale = 1.0, positionX = 0, positionY = 0,
|
|
@@ -20,16 +21,34 @@ export function ImagePicker({
|
|
|
20
21
|
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
|
21
22
|
const [isBrowserOpen, setIsBrowserOpen] = useState(false);
|
|
22
23
|
const [previewBaseScale, setPreviewBaseScale] = useState<number | null>(null);
|
|
24
|
+
const [previewImageError, setPreviewImageError] = useState(false);
|
|
23
25
|
const [mounted, setMounted] = useState(false);
|
|
26
|
+
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
|
|
24
27
|
const previewImageRef = useRef<HTMLImageElement>(null);
|
|
25
28
|
const previewContainerRef = useRef<HTMLDivElement>(null);
|
|
26
29
|
const editorRef = useRef<ImageEditorHandle | null>(null);
|
|
27
30
|
|
|
28
31
|
const { selectedImage, setSelectedImage } = useImagePicker({ value, images: [] });
|
|
29
32
|
|
|
30
|
-
//
|
|
33
|
+
// Reset preview error when selected image changes
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
setPreviewImageError(false);
|
|
36
|
+
}, [selectedImage?.id, selectedImage?.url]);
|
|
37
|
+
|
|
38
|
+
// Handle SSR & portal target - ensure we only render portal on client
|
|
31
39
|
useEffect(() => {
|
|
32
40
|
setMounted(true);
|
|
41
|
+
|
|
42
|
+
if (typeof document === 'undefined') return;
|
|
43
|
+
|
|
44
|
+
// If we're inside the GlobalImageEditor overlay, portal into that instead of document.body
|
|
45
|
+
// This ensures the browser/editor modals share the same stacking context
|
|
46
|
+
const editorContainer = previewContainerRef.current?.closest('[data-image-editor="true"]') as HTMLElement | null;
|
|
47
|
+
if (editorContainer) {
|
|
48
|
+
setPortalTarget(editorContainer);
|
|
49
|
+
} else {
|
|
50
|
+
setPortalTarget(document.body);
|
|
51
|
+
}
|
|
33
52
|
}, []);
|
|
34
53
|
|
|
35
54
|
// Auto-open editor if requested (e.g., when opening from edit button)
|
|
@@ -115,17 +134,36 @@ export function ImagePicker({
|
|
|
115
134
|
<div className="relative group max-w-md mx-auto">
|
|
116
135
|
<div className={`relative ${borderRadius} overflow-hidden border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 shadow-lg transition-all duration-300 hover:shadow-xl`} style={{ aspectRatio: aspectValue, width: '100%' }}>
|
|
117
136
|
<div ref={previewContainerRef} className="relative w-full h-full overflow-hidden">
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
{previewImageError ? (
|
|
138
|
+
<img
|
|
139
|
+
src={getFallbackImageUrl()}
|
|
140
|
+
alt={selectedImage.filename || 'Image not found'}
|
|
141
|
+
className="absolute max-w-none"
|
|
142
|
+
style={{
|
|
143
|
+
top: '50%', left: '50%', width: 'auto', height: 'auto',
|
|
144
|
+
minWidth: '100%', minHeight: '100%',
|
|
145
|
+
transform: 'translate(-50%, -50%)',
|
|
146
|
+
transformOrigin: 'center center',
|
|
147
|
+
objectFit: 'cover',
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
) : (
|
|
151
|
+
<img
|
|
152
|
+
ref={previewImageRef}
|
|
153
|
+
src={selectedImage.url}
|
|
154
|
+
alt={selectedImage.filename}
|
|
155
|
+
className="absolute max-w-none"
|
|
156
|
+
onLoad={calculatePreviewBaseScale}
|
|
157
|
+
onError={() => setPreviewImageError(true)}
|
|
158
|
+
style={{
|
|
159
|
+
top: '50%', left: '50%', width: 'auto', height: 'auto',
|
|
160
|
+
minWidth: '100%', minHeight: '100%',
|
|
161
|
+
filter: getImageFilter(brightness, blur),
|
|
162
|
+
transform: previewBaseScale ? getImageTransform({ scale: transforms.scale, positionX: transforms.positionX, positionY: transforms.positionY, baseScale: previewBaseScale }, true) : 'translate(-50%, -50%)',
|
|
163
|
+
transformOrigin: 'center center',
|
|
164
|
+
}}
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
129
167
|
</div>
|
|
130
168
|
{/* Overlay on hover */}
|
|
131
169
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors duration-300 pointer-events-none" />
|
|
@@ -161,8 +199,8 @@ export function ImagePicker({
|
|
|
161
199
|
</button>
|
|
162
200
|
</div>
|
|
163
201
|
|
|
164
|
-
{isEditorOpen && selectedImage && mounted && createPortal(
|
|
165
|
-
<div className="fixed inset-0 z-[
|
|
202
|
+
{isEditorOpen && selectedImage && mounted && portalTarget && createPortal(
|
|
203
|
+
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-neutral-950/80 dark:bg-neutral-950/90 backdrop-blur-md p-4 animate-in fade-in duration-200">
|
|
166
204
|
<div className="w-full max-w-4xl bg-white dark:bg-neutral-900 rounded-3xl overflow-hidden shadow-2xl dark:shadow-neutral-950/50 flex flex-col max-h-[85vh] border border-neutral-200 dark:border-neutral-800 animate-in zoom-in-95 duration-300">
|
|
167
205
|
<div className="flex-1 overflow-hidden p-4 lg:p-6">
|
|
168
206
|
<ImageEditor
|
|
@@ -201,7 +239,7 @@ export function ImagePicker({
|
|
|
201
239
|
</div>
|
|
202
240
|
</div>
|
|
203
241
|
</div>,
|
|
204
|
-
|
|
242
|
+
portalTarget
|
|
205
243
|
)}
|
|
206
244
|
|
|
207
245
|
<ImageBrowserModal
|
|
@@ -187,10 +187,21 @@ export function useImagePicker({ value, images }: UseImagePickerOptions) {
|
|
|
187
187
|
lastResolvedValueRef.current = value;
|
|
188
188
|
isResolvingRef.current = false;
|
|
189
189
|
} else {
|
|
190
|
-
// API resolution failed
|
|
190
|
+
// API resolution failed - check if it's a semantic ID or actual filename
|
|
191
|
+
const isLikelyFilename = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(imageIdToResolve) || /^\d+-/.test(imageIdToResolve);
|
|
192
|
+
|
|
193
|
+
if (!isLikelyFilename) {
|
|
194
|
+
// It's a semantic ID that couldn't be resolved - clear selection
|
|
195
|
+
// This prevents showing broken URLs like /api/uploads/blog-featured-...
|
|
196
|
+
setSelectedImage(null);
|
|
197
|
+
lastResolvedValueRef.current = value;
|
|
198
|
+
isResolvingRef.current = false;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// It's a filename, create fallback object
|
|
191
203
|
const isFullUrl = value.startsWith('http://') || value.startsWith('https://');
|
|
192
204
|
const isRelativeUrl = value.startsWith('/');
|
|
193
|
-
const isUrl = isFullUrl || isRelativeUrl;
|
|
194
205
|
|
|
195
206
|
let imageUrl: string;
|
|
196
207
|
if (isFullUrl) {
|
|
@@ -200,7 +211,7 @@ export function useImagePicker({ value, images }: UseImagePickerOptions) {
|
|
|
200
211
|
// Already a relative URL starting with /, use as-is
|
|
201
212
|
imageUrl = value;
|
|
202
213
|
} else {
|
|
203
|
-
// It's
|
|
214
|
+
// It's a filename, construct the URL
|
|
204
215
|
imageUrl = `/api/uploads/${value}`;
|
|
205
216
|
}
|
|
206
217
|
|
|
@@ -225,10 +236,21 @@ export function useImagePicker({ value, images }: UseImagePickerOptions) {
|
|
|
225
236
|
isResolvingRef.current = false;
|
|
226
237
|
}
|
|
227
238
|
} catch (error) {
|
|
228
|
-
// API call failed
|
|
239
|
+
// API call failed - check if it's a semantic ID or actual filename
|
|
240
|
+
const isLikelyFilename = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(imageIdToResolve) || /^\d+-/.test(imageIdToResolve);
|
|
241
|
+
|
|
242
|
+
if (!isLikelyFilename) {
|
|
243
|
+
// It's a semantic ID that couldn't be resolved - clear selection
|
|
244
|
+
// This prevents showing broken URLs like /api/uploads/blog-featured-...
|
|
245
|
+
setSelectedImage(null);
|
|
246
|
+
lastResolvedValueRef.current = value;
|
|
247
|
+
isResolvingRef.current = false;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// It's a filename, create fallback object
|
|
229
252
|
const isFullUrl = value.startsWith('http://') || value.startsWith('https://');
|
|
230
253
|
const isRelativeUrl = value.startsWith('/');
|
|
231
|
-
const isUrl = isFullUrl || isRelativeUrl;
|
|
232
254
|
|
|
233
255
|
let imageUrl: string;
|
|
234
256
|
if (isFullUrl) {
|
|
@@ -238,7 +260,7 @@ export function useImagePicker({ value, images }: UseImagePickerOptions) {
|
|
|
238
260
|
// Already a relative URL starting with /, use as-is
|
|
239
261
|
imageUrl = value;
|
|
240
262
|
} else {
|
|
241
|
-
// It's
|
|
263
|
+
// It's a filename, construct the URL
|
|
242
264
|
imageUrl = `/api/uploads/${value}`;
|
|
243
265
|
}
|
|
244
266
|
|
|
@@ -1,374 +0,0 @@
|
|
|
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, useCallback } from 'react';
|
|
11
|
-
import { X, Edit2 } from 'lucide-react';
|
|
12
|
-
import { ImagePicker } from '../ImagePicker';
|
|
13
|
-
import type { ImageMetadata } from '../../types';
|
|
14
|
-
import type { SelectedImage, PendingTransform } from './types';
|
|
15
|
-
import { getPluginConfig } from './config';
|
|
16
|
-
import { parseImageData } from './imageDetection';
|
|
17
|
-
import { setupImageHandlers } from './imageSetup';
|
|
18
|
-
import { saveTransformToAPI, flushPendingSave, getFilename, normalizePosition } from './saveLogic';
|
|
19
|
-
import { handleImageChange, handleBrightnessChange, handleBlurChange } from './eventHandlers';
|
|
20
|
-
|
|
21
|
-
export function GlobalImageEditor() {
|
|
22
|
-
// Configuration
|
|
23
|
-
const config = useMemo(() => getPluginConfig(), []);
|
|
24
|
-
|
|
25
|
-
// State
|
|
26
|
-
const [selectedImage, setSelectedImage] = useState<SelectedImage | null>(null);
|
|
27
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
28
|
-
const [userRole, setUserRole] = useState<string | null>(null);
|
|
29
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
30
|
-
|
|
31
|
-
// Refs for save debouncing
|
|
32
|
-
const saveTransformTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
33
|
-
const pendingTransformRef = useRef<PendingTransform | null>(null);
|
|
34
|
-
|
|
35
|
-
// Check if user is admin/dev
|
|
36
|
-
useEffect(() => {
|
|
37
|
-
const checkUser = async () => {
|
|
38
|
-
try {
|
|
39
|
-
const res = await fetch('/api/me');
|
|
40
|
-
const data = await res.json();
|
|
41
|
-
if (data.loggedIn && (data.user?.role === 'admin' || data.user?.role === 'dev')) {
|
|
42
|
-
setUserRole(data.user.role);
|
|
43
|
-
}
|
|
44
|
-
} catch (error) {
|
|
45
|
-
console.error('Failed to check user role:', error);
|
|
46
|
-
} finally {
|
|
47
|
-
setIsLoading(false);
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
checkUser();
|
|
51
|
-
}, []);
|
|
52
|
-
|
|
53
|
-
// Listen for open-image-editor custom event from Image/BackgroundImage components
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
if (!userRole) return;
|
|
56
|
-
|
|
57
|
-
const handleOpenEditor = async (e: CustomEvent) => {
|
|
58
|
-
const { id, currentBrightness, currentBlur } = e.detail || {};
|
|
59
|
-
if (!id) return;
|
|
60
|
-
|
|
61
|
-
console.log('[GlobalImageEditor] open-image-editor event received for id:', id);
|
|
62
|
-
|
|
63
|
-
// Find the element by data-image-id or data-background-image-id
|
|
64
|
-
let element = document.querySelector(`[data-image-id="${id}"], [data-background-image-id="${id}"]`) as HTMLElement;
|
|
65
|
-
|
|
66
|
-
if (!element) {
|
|
67
|
-
element = document.querySelector(`[data-background-image-component="true"][data-background-image-id="${id}"]`) as HTMLElement;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (!element) {
|
|
71
|
-
console.error('[GlobalImageEditor] Element not found for id:', id);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Extract semantic ID
|
|
76
|
-
const semanticId = element.getAttribute('data-image-id') ||
|
|
77
|
-
element.getAttribute('data-background-image-id') ||
|
|
78
|
-
id;
|
|
79
|
-
|
|
80
|
-
// Parse image data
|
|
81
|
-
const imageData = parseImageData(element, semanticId, currentBrightness, currentBlur);
|
|
82
|
-
|
|
83
|
-
if (!imageData) {
|
|
84
|
-
console.error('[GlobalImageEditor] Failed to parse image data');
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Store semantic ID on element if not already set
|
|
89
|
-
if (imageData.isBackground && !element.hasAttribute('data-background-image-id')) {
|
|
90
|
-
element.setAttribute('data-background-image-id', semanticId);
|
|
91
|
-
} else if (!imageData.isBackground && !element.hasAttribute('data-image-id')) {
|
|
92
|
-
element.setAttribute('data-image-id', semanticId);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
setSelectedImage(imageData);
|
|
96
|
-
setIsOpen(true);
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
window.addEventListener('open-image-editor', handleOpenEditor as unknown as EventListener);
|
|
100
|
-
|
|
101
|
-
return () => {
|
|
102
|
-
window.removeEventListener('open-image-editor', handleOpenEditor as unknown as EventListener);
|
|
103
|
-
};
|
|
104
|
-
}, [userRole]);
|
|
105
|
-
|
|
106
|
-
// Add click handlers to all images
|
|
107
|
-
useEffect(() => {
|
|
108
|
-
if (isLoading || !userRole) {
|
|
109
|
-
console.log('[GlobalImageEditor] Skipping image handlers - isLoading:', isLoading, 'userRole:', userRole);
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
console.log('[GlobalImageEditor] Setting up image handlers for role:', userRole);
|
|
114
|
-
|
|
115
|
-
const cleanup = setupImageHandlers((imageData) => {
|
|
116
|
-
setSelectedImage(imageData);
|
|
117
|
-
setIsOpen(true);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// Setup immediately and also after a short delay to catch dynamically loaded images
|
|
121
|
-
const timeoutId = setTimeout(() => {
|
|
122
|
-
const cleanup2 = setupImageHandlers((imageData) => {
|
|
123
|
-
setSelectedImage(imageData);
|
|
124
|
-
setIsOpen(true);
|
|
125
|
-
});
|
|
126
|
-
// Note: This creates a new cleanup, but the first one will handle the initial setup
|
|
127
|
-
}, 500);
|
|
128
|
-
|
|
129
|
-
return () => {
|
|
130
|
-
clearTimeout(timeoutId);
|
|
131
|
-
cleanup();
|
|
132
|
-
};
|
|
133
|
-
}, [isLoading, userRole]);
|
|
134
|
-
|
|
135
|
-
// Save image transform (scale/position) to API with debouncing
|
|
136
|
-
const saveImageTransformHandler = useCallback(async (
|
|
137
|
-
scale: number,
|
|
138
|
-
positionX: number,
|
|
139
|
-
positionY: number,
|
|
140
|
-
immediate: boolean = false,
|
|
141
|
-
brightnessOverride?: number,
|
|
142
|
-
blurOverride?: number
|
|
143
|
-
) => {
|
|
144
|
-
if (!selectedImage) return;
|
|
145
|
-
|
|
146
|
-
const { element } = selectedImage;
|
|
147
|
-
const semanticId = element.getAttribute('data-image-id') ||
|
|
148
|
-
element.getAttribute('data-background-image-id');
|
|
149
|
-
|
|
150
|
-
if (!semanticId) return;
|
|
151
|
-
|
|
152
|
-
const filename = await getFilename(semanticId, selectedImage);
|
|
153
|
-
if (!filename) {
|
|
154
|
-
console.error('[GlobalImageEditor] getFilename returned null for semanticId:', semanticId);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
console.log('[GlobalImageEditor] getFilename returned:', filename, 'for semanticId:', semanticId);
|
|
158
|
-
|
|
159
|
-
// Use override values if provided, otherwise use selectedImage values
|
|
160
|
-
// IMPORTANT: Overrides contain the latest values from the editor
|
|
161
|
-
const finalBrightness = brightnessOverride !== undefined ? brightnessOverride : (selectedImage?.brightness ?? 100);
|
|
162
|
-
const finalBlur = blurOverride !== undefined ? blurOverride : (selectedImage?.blur ?? 0);
|
|
163
|
-
|
|
164
|
-
// Store pending transform
|
|
165
|
-
pendingTransformRef.current = {
|
|
166
|
-
scale,
|
|
167
|
-
positionX,
|
|
168
|
-
positionY,
|
|
169
|
-
semanticId,
|
|
170
|
-
filename
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
// Clear existing timeout
|
|
174
|
-
if (saveTransformTimeoutRef.current) {
|
|
175
|
-
clearTimeout(saveTransformTimeoutRef.current);
|
|
176
|
-
saveTransformTimeoutRef.current = null;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// If immediate save is requested (e.g., when "Done" is clicked), save right away
|
|
180
|
-
if (immediate) {
|
|
181
|
-
const normalizedPositionX = normalizePosition(positionX);
|
|
182
|
-
const normalizedPositionY = normalizePosition(positionY);
|
|
183
|
-
|
|
184
|
-
console.log('[GlobalImageEditor] Immediate save to API:', { semanticId, filename, scale, normalizedPositionX, normalizedPositionY, finalBrightness, finalBlur });
|
|
185
|
-
await saveTransformToAPI(
|
|
186
|
-
semanticId,
|
|
187
|
-
filename,
|
|
188
|
-
scale,
|
|
189
|
-
normalizedPositionX,
|
|
190
|
-
normalizedPositionY,
|
|
191
|
-
finalBrightness,
|
|
192
|
-
finalBlur
|
|
193
|
-
);
|
|
194
|
-
pendingTransformRef.current = null;
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Debounce: save after 300ms of no changes
|
|
199
|
-
saveTransformTimeoutRef.current = setTimeout(async () => {
|
|
200
|
-
const pending = pendingTransformRef.current;
|
|
201
|
-
if (pending && selectedImage) {
|
|
202
|
-
await flushPendingSave(pending, selectedImage);
|
|
203
|
-
}
|
|
204
|
-
}, 300);
|
|
205
|
-
}, [selectedImage]);
|
|
206
|
-
|
|
207
|
-
// Cleanup timeout on unmount
|
|
208
|
-
useEffect(() => {
|
|
209
|
-
return () => {
|
|
210
|
-
if (saveTransformTimeoutRef.current) {
|
|
211
|
-
clearTimeout(saveTransformTimeoutRef.current);
|
|
212
|
-
}
|
|
213
|
-
};
|
|
214
|
-
}, []);
|
|
215
|
-
|
|
216
|
-
// Event handlers
|
|
217
|
-
const handleImageChangeWrapper = useCallback(async (image: ImageMetadata | null) => {
|
|
218
|
-
if (!selectedImage) return;
|
|
219
|
-
await handleImageChange(image, selectedImage, handleClose);
|
|
220
|
-
}, [selectedImage]);
|
|
221
|
-
|
|
222
|
-
const handleBrightnessChangeWrapper = useCallback(async (brightness: number) => {
|
|
223
|
-
if (!selectedImage) return;
|
|
224
|
-
// Don't save immediately when in editor - will be saved when "Done" is pressed via onEditorSave
|
|
225
|
-
await handleBrightnessChange(brightness, selectedImage, setSelectedImage, false);
|
|
226
|
-
}, [selectedImage]);
|
|
227
|
-
|
|
228
|
-
const handleBlurChangeWrapper = useCallback(async (blur: number) => {
|
|
229
|
-
if (!selectedImage) return;
|
|
230
|
-
// Don't save immediately when in editor - will be saved when "Done" is pressed via onEditorSave
|
|
231
|
-
await handleBlurChange(blur, selectedImage, setSelectedImage, false);
|
|
232
|
-
}, [selectedImage]);
|
|
233
|
-
|
|
234
|
-
const handleEditorSave = useCallback(async (
|
|
235
|
-
finalScale: number,
|
|
236
|
-
finalPositionX: number,
|
|
237
|
-
finalPositionY: number,
|
|
238
|
-
finalBrightness?: number,
|
|
239
|
-
finalBlur?: number
|
|
240
|
-
) => {
|
|
241
|
-
console.log('[GlobalImageEditor] handleEditorSave callback created/updated');
|
|
242
|
-
console.log('[GlobalImageEditor] handleEditorSave called:', { finalScale, finalPositionX, finalPositionY, finalBrightness, finalBlur, hasSelectedImage: !!selectedImage });
|
|
243
|
-
if (selectedImage) {
|
|
244
|
-
// Use provided values if they're not undefined, otherwise fall back to selectedImage values
|
|
245
|
-
// Note: 0 is a valid value for blur, so we check for undefined explicitly
|
|
246
|
-
const brightness = finalBrightness !== undefined ? finalBrightness : selectedImage.brightness;
|
|
247
|
-
const blur = finalBlur !== undefined ? finalBlur : selectedImage.blur;
|
|
248
|
-
|
|
249
|
-
const updated = {
|
|
250
|
-
...selectedImage,
|
|
251
|
-
scale: finalScale,
|
|
252
|
-
positionX: finalPositionX,
|
|
253
|
-
positionY: finalPositionY,
|
|
254
|
-
brightness,
|
|
255
|
-
blur,
|
|
256
|
-
};
|
|
257
|
-
setSelectedImage(updated);
|
|
258
|
-
|
|
259
|
-
console.log('[GlobalImageEditor] Calling saveImageTransformHandler with:', { finalScale, finalPositionX, finalPositionY, brightness, blur, immediate: true });
|
|
260
|
-
// Only save once when editor closes with all final values
|
|
261
|
-
// ImagePicker prevents duplicate calls by not calling individual handlers when onEditorSave is provided
|
|
262
|
-
await saveImageTransformHandler(
|
|
263
|
-
finalScale,
|
|
264
|
-
finalPositionX,
|
|
265
|
-
finalPositionY,
|
|
266
|
-
true,
|
|
267
|
-
brightness,
|
|
268
|
-
blur
|
|
269
|
-
);
|
|
270
|
-
} else {
|
|
271
|
-
console.error('[GlobalImageEditor] handleEditorSave called but selectedImage is null!');
|
|
272
|
-
}
|
|
273
|
-
}, [selectedImage, saveImageTransformHandler]);
|
|
274
|
-
|
|
275
|
-
const handleClose = useCallback(() => {
|
|
276
|
-
setIsOpen(false);
|
|
277
|
-
setSelectedImage(null);
|
|
278
|
-
}, []);
|
|
279
|
-
|
|
280
|
-
// Don't render if disabled or user is not admin/dev
|
|
281
|
-
if (!config.enabled || isLoading || !userRole) {
|
|
282
|
-
return null;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
if (!isOpen || !selectedImage) {
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
return (
|
|
290
|
-
<div
|
|
291
|
-
className={`fixed inset-0 z-[9999] flex items-center justify-center ${config.overlayClassName || 'bg-black/50 backdrop-blur-sm'}`}
|
|
292
|
-
data-image-editor="true"
|
|
293
|
-
onClick={(e) => {
|
|
294
|
-
if (e.target === e.currentTarget) {
|
|
295
|
-
handleClose();
|
|
296
|
-
}
|
|
297
|
-
}}
|
|
298
|
-
>
|
|
299
|
-
<div
|
|
300
|
-
className={`relative bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-7xl w-full mx-4 max-h-[90vh] flex flex-col ${config.className || ''}`}
|
|
301
|
-
onClick={(e) => e.stopPropagation()}
|
|
302
|
-
>
|
|
303
|
-
{/* Header */}
|
|
304
|
-
<div className="flex items-center justify-between p-6 border-b dark:border-neutral-800">
|
|
305
|
-
<h2 className="text-2xl font-bold dark:text-white">Edit Image</h2>
|
|
306
|
-
<button
|
|
307
|
-
onClick={handleClose}
|
|
308
|
-
className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
|
309
|
-
aria-label="Close editor"
|
|
310
|
-
>
|
|
311
|
-
<X className="w-5 h-5" />
|
|
312
|
-
</button>
|
|
313
|
-
</div>
|
|
314
|
-
|
|
315
|
-
{/* Content */}
|
|
316
|
-
<div className="p-6">
|
|
317
|
-
<ImagePicker
|
|
318
|
-
value={selectedImage.element.getAttribute('data-image-id') || selectedImage.element.getAttribute('data-background-image-id') || selectedImage.originalSrc}
|
|
319
|
-
onChange={handleImageChangeWrapper}
|
|
320
|
-
brightness={selectedImage.brightness}
|
|
321
|
-
blur={selectedImage.blur}
|
|
322
|
-
scale={selectedImage.scale}
|
|
323
|
-
positionX={selectedImage.positionX}
|
|
324
|
-
positionY={selectedImage.positionY}
|
|
325
|
-
onBrightnessChange={handleBrightnessChangeWrapper}
|
|
326
|
-
onBlurChange={handleBlurChangeWrapper}
|
|
327
|
-
onEditorSave={handleEditorSave}
|
|
328
|
-
onScaleChange={async (newScale) => {
|
|
329
|
-
console.log('[GlobalImageEditor] onScaleChange called:', newScale);
|
|
330
|
-
if (selectedImage) {
|
|
331
|
-
const updated = { ...selectedImage, scale: newScale };
|
|
332
|
-
setSelectedImage(updated);
|
|
333
|
-
await saveImageTransformHandler(newScale, updated.positionX, updated.positionY, false);
|
|
334
|
-
}
|
|
335
|
-
}}
|
|
336
|
-
onPositionXChange={async (newPositionX) => {
|
|
337
|
-
console.log('[GlobalImageEditor] onPositionXChange called:', newPositionX);
|
|
338
|
-
if (selectedImage) {
|
|
339
|
-
const updated = { ...selectedImage, positionX: newPositionX };
|
|
340
|
-
setSelectedImage(updated);
|
|
341
|
-
await saveImageTransformHandler(updated.scale, newPositionX, updated.positionY, false);
|
|
342
|
-
}
|
|
343
|
-
}}
|
|
344
|
-
onPositionYChange={async (newPositionY) => {
|
|
345
|
-
console.log('[GlobalImageEditor] onPositionYChange called:', newPositionY);
|
|
346
|
-
if (selectedImage) {
|
|
347
|
-
const updated = { ...selectedImage, positionY: newPositionY };
|
|
348
|
-
setSelectedImage(updated);
|
|
349
|
-
// If onEditorSave wasn't provided, this might be called when editor closes
|
|
350
|
-
// In that case, we should save immediately with all current values including brightness/blur
|
|
351
|
-
// We'll save immediately to ensure the final state is persisted
|
|
352
|
-
await saveImageTransformHandler(
|
|
353
|
-
updated.scale,
|
|
354
|
-
updated.positionX,
|
|
355
|
-
newPositionY,
|
|
356
|
-
true, // Save immediately - this is likely the final callback when editor closes
|
|
357
|
-
updated.brightness,
|
|
358
|
-
updated.blur
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
}}
|
|
362
|
-
onEditorSave={handleEditorSave}
|
|
363
|
-
showEffects={true}
|
|
364
|
-
darkMode={false}
|
|
365
|
-
aspectRatio={selectedImage.aspectRatio}
|
|
366
|
-
borderRadius={selectedImage.borderRadius}
|
|
367
|
-
objectFit={selectedImage.objectFit}
|
|
368
|
-
objectPosition={selectedImage.objectPosition}
|
|
369
|
-
/>
|
|
370
|
-
</div>
|
|
371
|
-
</div>
|
|
372
|
-
</div>
|
|
373
|
-
);
|
|
374
|
-
}
|