@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,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Picker Component
|
|
3
|
+
* Allows uploading, searching, and selecting images with brightness/blur controls
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
9
|
+
import { Upload, Search, X, Image as ImageIcon, SlidersHorizontal, Check, Link as LinkIcon } from 'lucide-react';
|
|
10
|
+
import type { ImageMetadata, ImagePickerProps } from '../types';
|
|
11
|
+
|
|
12
|
+
export function ImagePicker({
|
|
13
|
+
value,
|
|
14
|
+
onChange,
|
|
15
|
+
darkMode = false,
|
|
16
|
+
showEffects = true,
|
|
17
|
+
brightness = 100,
|
|
18
|
+
blur = 0,
|
|
19
|
+
onBrightnessChange,
|
|
20
|
+
onBlurChange,
|
|
21
|
+
}: ImagePickerProps) {
|
|
22
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
23
|
+
const [images, setImages] = useState<ImageMetadata[]>([]);
|
|
24
|
+
const [loading, setLoading] = useState(false);
|
|
25
|
+
const [uploading, setUploading] = useState(false);
|
|
26
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
27
|
+
const [selectedImage, setSelectedImage] = useState<ImageMetadata | null>(null);
|
|
28
|
+
const [page, setPage] = useState(1);
|
|
29
|
+
const [hasMore, setHasMore] = useState(true);
|
|
30
|
+
const [externalUrl, setExternalUrl] = useState('');
|
|
31
|
+
const [showUrlInput, setShowUrlInput] = useState(false);
|
|
32
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
33
|
+
const modalRef = useRef<HTMLDivElement>(null);
|
|
34
|
+
|
|
35
|
+
// Load images
|
|
36
|
+
const loadImages = async (reset = false) => {
|
|
37
|
+
if (loading) return;
|
|
38
|
+
|
|
39
|
+
setLoading(true);
|
|
40
|
+
try {
|
|
41
|
+
const currentPage = reset ? 1 : page;
|
|
42
|
+
const response = await fetch(
|
|
43
|
+
`/api/plugin-images/list?page=${currentPage}&limit=20&search=${encodeURIComponent(searchQuery)}`
|
|
44
|
+
);
|
|
45
|
+
const data = await response.json();
|
|
46
|
+
|
|
47
|
+
if (reset) {
|
|
48
|
+
setImages(data.images);
|
|
49
|
+
setPage(2);
|
|
50
|
+
} else {
|
|
51
|
+
setImages(prev => [...prev, ...data.images]);
|
|
52
|
+
setPage(prev => prev + 1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setHasMore(data.images.length === 20);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('Failed to load images:', error);
|
|
58
|
+
} finally {
|
|
59
|
+
setLoading(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Load images when modal opens
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (isOpen) {
|
|
66
|
+
loadImages(true);
|
|
67
|
+
}
|
|
68
|
+
}, [isOpen, searchQuery]);
|
|
69
|
+
|
|
70
|
+
// Find selected image from value (can be ID or URL)
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (value) {
|
|
73
|
+
// First, try to find by ID (preferred method)
|
|
74
|
+
let found = images.find(img => img.id === value);
|
|
75
|
+
|
|
76
|
+
// If not found by ID, try to find by URL
|
|
77
|
+
if (!found) {
|
|
78
|
+
found = images.find(img => img.url === value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (found) {
|
|
82
|
+
setSelectedImage(found);
|
|
83
|
+
} else if (!selectedImage) {
|
|
84
|
+
// Create a temporary image object from the value if not found in list
|
|
85
|
+
// This handles cases where the image was set externally or is an ID
|
|
86
|
+
const isUrl = value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/');
|
|
87
|
+
const urlParts = isUrl ? value.split('/') : [];
|
|
88
|
+
const filename = isUrl ? urlParts[urlParts.length - 1] : value;
|
|
89
|
+
|
|
90
|
+
setSelectedImage({
|
|
91
|
+
id: isUrl ? filename : value, // Use value as ID if it's not a URL
|
|
92
|
+
filename,
|
|
93
|
+
url: isUrl ? value : `/api/uploads/${value}`, // Construct URL if value is an ID
|
|
94
|
+
size: 0,
|
|
95
|
+
mimeType: 'image/jpeg',
|
|
96
|
+
uploadedAt: new Date().toISOString(),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
setSelectedImage(null);
|
|
101
|
+
}
|
|
102
|
+
}, [value, images]);
|
|
103
|
+
|
|
104
|
+
// Handle file upload
|
|
105
|
+
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
106
|
+
const file = e.target.files?.[0];
|
|
107
|
+
if (!file) return;
|
|
108
|
+
|
|
109
|
+
setUploading(true);
|
|
110
|
+
try {
|
|
111
|
+
const formData = new FormData();
|
|
112
|
+
formData.append('file', file);
|
|
113
|
+
|
|
114
|
+
const response = await fetch('/api/plugin-images/upload', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
body: formData,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const data = await response.json();
|
|
120
|
+
|
|
121
|
+
if (data.success && data.image) {
|
|
122
|
+
// Reload images list
|
|
123
|
+
await loadImages(true);
|
|
124
|
+
// Select the newly uploaded image
|
|
125
|
+
setSelectedImage(data.image);
|
|
126
|
+
onChange(data.image);
|
|
127
|
+
} else {
|
|
128
|
+
alert(data.error || 'Failed to upload image');
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error('Upload error:', error);
|
|
132
|
+
alert('Failed to upload image');
|
|
133
|
+
} finally {
|
|
134
|
+
setUploading(false);
|
|
135
|
+
if (fileInputRef.current) {
|
|
136
|
+
fileInputRef.current.value = '';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Handle image selection
|
|
142
|
+
const handleSelectImage = (image: ImageMetadata) => {
|
|
143
|
+
setSelectedImage(image);
|
|
144
|
+
onChange(image);
|
|
145
|
+
// Reset effects when selecting a new image (optional - could preserve them)
|
|
146
|
+
// onBrightnessChange?.(100);
|
|
147
|
+
// onBlurChange?.(0);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Handle remove image
|
|
151
|
+
const handleRemove = () => {
|
|
152
|
+
setSelectedImage(null);
|
|
153
|
+
onChange(null);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Validate if URL is an image
|
|
157
|
+
const isValidImageUrl = (url: string): boolean => {
|
|
158
|
+
try {
|
|
159
|
+
const urlObj = new URL(url);
|
|
160
|
+
const pathname = urlObj.pathname.toLowerCase();
|
|
161
|
+
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg'];
|
|
162
|
+
return imageExtensions.some(ext => pathname.endsWith(ext)) ||
|
|
163
|
+
urlObj.hostname.includes('unsplash.com') ||
|
|
164
|
+
urlObj.hostname.includes('pixabay.com') ||
|
|
165
|
+
urlObj.hostname.includes('pexels.com') ||
|
|
166
|
+
url.includes('image') ||
|
|
167
|
+
url.includes('img');
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Handle external URL
|
|
174
|
+
const handleUseExternalUrl = () => {
|
|
175
|
+
if (!externalUrl.trim()) {
|
|
176
|
+
alert('Please enter an image URL');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!isValidImageUrl(externalUrl)) {
|
|
181
|
+
if (!confirm('This might not be a valid image URL. Continue anyway?')) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Create image metadata from external URL
|
|
187
|
+
const urlParts = externalUrl.split('/');
|
|
188
|
+
const filename = urlParts[urlParts.length - 1].split('?')[0] || 'external-image.jpg';
|
|
189
|
+
|
|
190
|
+
const externalImage: ImageMetadata = {
|
|
191
|
+
id: `external-${Date.now()}`,
|
|
192
|
+
filename,
|
|
193
|
+
url: externalUrl,
|
|
194
|
+
size: 0,
|
|
195
|
+
mimeType: 'image/jpeg',
|
|
196
|
+
uploadedAt: new Date().toISOString(),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
setSelectedImage(externalImage);
|
|
200
|
+
onChange(externalImage);
|
|
201
|
+
setExternalUrl('');
|
|
202
|
+
setShowUrlInput(false);
|
|
203
|
+
setIsOpen(false);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Close modal on outside click
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
209
|
+
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
|
210
|
+
setIsOpen(false);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (isOpen) {
|
|
215
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
216
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
217
|
+
}
|
|
218
|
+
}, [isOpen]);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div className="space-y-4">
|
|
222
|
+
{/* Current Image Preview */}
|
|
223
|
+
{selectedImage ? (
|
|
224
|
+
<div className="relative group">
|
|
225
|
+
<div className="relative rounded-xl overflow-hidden border-2 border-dashboard-border aspect-video bg-dashboard-bg">
|
|
226
|
+
<img
|
|
227
|
+
src={selectedImage.url}
|
|
228
|
+
alt={selectedImage.alt || selectedImage.filename}
|
|
229
|
+
className="w-full h-full object-cover"
|
|
230
|
+
style={{
|
|
231
|
+
filter: `brightness(${brightness}%) blur(${blur}px)`,
|
|
232
|
+
}}
|
|
233
|
+
/>
|
|
234
|
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
|
235
|
+
<button
|
|
236
|
+
onClick={handleRemove}
|
|
237
|
+
className="opacity-0 group-hover:opacity-100 p-2 bg-red-500 text-white rounded-full transition-opacity"
|
|
238
|
+
>
|
|
239
|
+
<X size={16} />
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2 truncate">
|
|
244
|
+
{selectedImage.filename}
|
|
245
|
+
</p>
|
|
246
|
+
</div>
|
|
247
|
+
) : (
|
|
248
|
+
<div
|
|
249
|
+
onClick={() => setIsOpen(true)}
|
|
250
|
+
className="relative aspect-video bg-dashboard-bg rounded-xl border-2 border-dashed border-dashboard-border flex flex-col items-center justify-center text-neutral-400 dark:text-neutral-500 hover:bg-dashboard-card hover:border-primary cursor-pointer transition-all"
|
|
251
|
+
>
|
|
252
|
+
<ImageIcon size={32} className="mb-2" />
|
|
253
|
+
<span className="text-xs font-bold uppercase tracking-wider">Select Image</span>
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{/* Action Buttons */}
|
|
258
|
+
<div className="flex gap-2">
|
|
259
|
+
<button
|
|
260
|
+
onClick={() => setIsOpen(true)}
|
|
261
|
+
className="flex-1 px-4 py-2 bg-dashboard-card border border-dashboard-border rounded-xl text-sm font-bold text-dashboard-text hover:bg-dashboard-bg transition-colors flex items-center justify-center gap-2"
|
|
262
|
+
>
|
|
263
|
+
<Search size={16} />
|
|
264
|
+
Browse
|
|
265
|
+
</button>
|
|
266
|
+
<button
|
|
267
|
+
onClick={() => fileInputRef.current?.click()}
|
|
268
|
+
disabled={uploading}
|
|
269
|
+
className="flex-1 px-4 py-2 bg-primary text-white rounded-xl text-sm font-bold hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
|
270
|
+
>
|
|
271
|
+
<Upload size={16} />
|
|
272
|
+
{uploading ? 'Uploading...' : 'Upload'}
|
|
273
|
+
</button>
|
|
274
|
+
<button
|
|
275
|
+
onClick={() => setShowUrlInput(!showUrlInput)}
|
|
276
|
+
className="px-4 py-2 bg-dashboard-card border border-dashboard-border rounded-xl text-sm font-bold text-dashboard-text hover:bg-dashboard-bg transition-colors flex items-center justify-center gap-2"
|
|
277
|
+
>
|
|
278
|
+
<LinkIcon size={16} />
|
|
279
|
+
URL
|
|
280
|
+
</button>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{/* External URL Input */}
|
|
284
|
+
{showUrlInput && (
|
|
285
|
+
<div className="p-4 bg-dashboard-bg rounded-xl border border-dashboard-border space-y-2">
|
|
286
|
+
<label className="text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase">
|
|
287
|
+
External Image URL
|
|
288
|
+
</label>
|
|
289
|
+
<div className="flex gap-2">
|
|
290
|
+
<input
|
|
291
|
+
type="url"
|
|
292
|
+
value={externalUrl}
|
|
293
|
+
onChange={(e) => setExternalUrl(e.target.value)}
|
|
294
|
+
onKeyDown={(e) => {
|
|
295
|
+
if (e.key === 'Enter') {
|
|
296
|
+
handleUseExternalUrl();
|
|
297
|
+
}
|
|
298
|
+
}}
|
|
299
|
+
placeholder="https://example.com/image.jpg"
|
|
300
|
+
className="flex-1 px-3 py-2 bg-white dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-700 rounded-lg text-sm font-bold outline-none focus:border-primary transition-all dark:text-neutral-100"
|
|
301
|
+
/>
|
|
302
|
+
<button
|
|
303
|
+
onClick={handleUseExternalUrl}
|
|
304
|
+
className="px-4 py-2 bg-primary text-white rounded-lg text-sm font-bold hover:bg-primary/90 transition-colors"
|
|
305
|
+
>
|
|
306
|
+
Use
|
|
307
|
+
</button>
|
|
308
|
+
<button
|
|
309
|
+
onClick={() => {
|
|
310
|
+
setShowUrlInput(false);
|
|
311
|
+
setExternalUrl('');
|
|
312
|
+
}}
|
|
313
|
+
className="px-4 py-2 bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400 rounded-lg text-sm font-bold hover:bg-neutral-300 dark:hover:bg-neutral-600 transition-colors"
|
|
314
|
+
>
|
|
315
|
+
Cancel
|
|
316
|
+
</button>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
|
|
321
|
+
{/* Effects Controls */}
|
|
322
|
+
{showEffects && selectedImage && (
|
|
323
|
+
<div className="space-y-3 p-4 bg-dashboard-bg rounded-xl border border-dashboard-border">
|
|
324
|
+
<div className="flex items-center gap-2 mb-3">
|
|
325
|
+
<SlidersHorizontal size={14} className="text-neutral-500 dark:text-neutral-400" />
|
|
326
|
+
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold">
|
|
327
|
+
Image Effects
|
|
328
|
+
</label>
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
{/* Brightness */}
|
|
332
|
+
<div>
|
|
333
|
+
<div className="flex items-center justify-between mb-2">
|
|
334
|
+
<label className="text-xs font-bold text-neutral-600 dark:text-neutral-400">
|
|
335
|
+
Brightness
|
|
336
|
+
</label>
|
|
337
|
+
<span className="text-xs font-bold text-neutral-500 dark:text-neutral-500">
|
|
338
|
+
{brightness}%
|
|
339
|
+
</span>
|
|
340
|
+
</div>
|
|
341
|
+
<input
|
|
342
|
+
type="range"
|
|
343
|
+
min="0"
|
|
344
|
+
max="200"
|
|
345
|
+
value={brightness}
|
|
346
|
+
onChange={(e) => onBrightnessChange?.(parseInt(e.target.value))}
|
|
347
|
+
className="w-full h-2 bg-neutral-200 dark:bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-primary"
|
|
348
|
+
/>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{/* Blur */}
|
|
352
|
+
<div>
|
|
353
|
+
<div className="flex items-center justify-between mb-2">
|
|
354
|
+
<label className="text-xs font-bold text-neutral-600 dark:text-neutral-400">
|
|
355
|
+
Blur
|
|
356
|
+
</label>
|
|
357
|
+
<span className="text-xs font-bold text-neutral-500 dark:text-neutral-500">
|
|
358
|
+
{blur}px
|
|
359
|
+
</span>
|
|
360
|
+
</div>
|
|
361
|
+
<input
|
|
362
|
+
type="range"
|
|
363
|
+
min="0"
|
|
364
|
+
max="20"
|
|
365
|
+
value={blur}
|
|
366
|
+
onChange={(e) => onBlurChange?.(parseInt(e.target.value))}
|
|
367
|
+
className="w-full h-2 bg-neutral-200 dark:bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-primary"
|
|
368
|
+
/>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
{/* Hidden file input */}
|
|
374
|
+
<input
|
|
375
|
+
ref={fileInputRef}
|
|
376
|
+
type="file"
|
|
377
|
+
accept="image/*"
|
|
378
|
+
onChange={handleFileSelect}
|
|
379
|
+
className="hidden"
|
|
380
|
+
/>
|
|
381
|
+
|
|
382
|
+
{/* Image Browser Modal */}
|
|
383
|
+
{isOpen && (
|
|
384
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
385
|
+
<div
|
|
386
|
+
ref={modalRef}
|
|
387
|
+
className={`w-full max-w-4xl max-h-[80vh] bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 shadow-2xl flex flex-col ${
|
|
388
|
+
darkMode ? 'dark' : ''
|
|
389
|
+
}`}
|
|
390
|
+
>
|
|
391
|
+
{/* Modal Header */}
|
|
392
|
+
<div className="p-6 border-b border-dashboard-border flex items-center justify-between">
|
|
393
|
+
<h2 className="text-lg font-black uppercase tracking-tighter text-dashboard-text">
|
|
394
|
+
Select Image
|
|
395
|
+
</h2>
|
|
396
|
+
<button
|
|
397
|
+
onClick={() => setIsOpen(false)}
|
|
398
|
+
className="p-2 hover:bg-dashboard-bg rounded-lg transition-colors"
|
|
399
|
+
>
|
|
400
|
+
<X size={20} className="text-neutral-500 dark:text-neutral-400" />
|
|
401
|
+
</button>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
{/* Search Bar */}
|
|
405
|
+
<div className="p-4 border-b border-dashboard-border">
|
|
406
|
+
<div className="relative">
|
|
407
|
+
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-500" />
|
|
408
|
+
<input
|
|
409
|
+
type="text"
|
|
410
|
+
value={searchQuery}
|
|
411
|
+
onChange={(e) => {
|
|
412
|
+
setSearchQuery(e.target.value);
|
|
413
|
+
setPage(1);
|
|
414
|
+
}}
|
|
415
|
+
placeholder="Search images..."
|
|
416
|
+
className="w-full pl-10 pr-4 py-2 bg-dashboard-card border border-dashboard-border rounded-xl text-sm font-bold outline-none focus:border-primary transition-all text-dashboard-text"
|
|
417
|
+
/>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
{/* Image Grid */}
|
|
422
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
423
|
+
{loading && images.length === 0 ? (
|
|
424
|
+
<div className="text-center py-12">
|
|
425
|
+
<div className="animate-pulse text-neutral-400 dark:text-neutral-500">
|
|
426
|
+
Loading images...
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
) : images.length === 0 ? (
|
|
430
|
+
<div className="text-center py-12">
|
|
431
|
+
<ImageIcon size={48} className="mx-auto text-neutral-300 dark:text-neutral-700 mb-4" />
|
|
432
|
+
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-2">
|
|
433
|
+
No images found
|
|
434
|
+
</p>
|
|
435
|
+
<button
|
|
436
|
+
onClick={() => fileInputRef.current?.click()}
|
|
437
|
+
className="text-sm font-bold text-primary hover:underline"
|
|
438
|
+
>
|
|
439
|
+
Upload your first image
|
|
440
|
+
</button>
|
|
441
|
+
</div>
|
|
442
|
+
) : (
|
|
443
|
+
<div className="grid grid-cols-4 gap-4">
|
|
444
|
+
{images.map((image) => (
|
|
445
|
+
<button
|
|
446
|
+
key={image.id}
|
|
447
|
+
onClick={() => {
|
|
448
|
+
handleSelectImage(image);
|
|
449
|
+
setIsOpen(false);
|
|
450
|
+
}}
|
|
451
|
+
className={`relative aspect-square rounded-xl overflow-hidden border-2 transition-all ${
|
|
452
|
+
selectedImage?.id === image.id
|
|
453
|
+
? 'border-primary ring-2 ring-primary/20'
|
|
454
|
+
: 'border-neutral-200 dark:border-neutral-700 hover:border-primary/50'
|
|
455
|
+
}`}
|
|
456
|
+
>
|
|
457
|
+
<img
|
|
458
|
+
src={image.url}
|
|
459
|
+
alt={image.alt || image.filename}
|
|
460
|
+
className="w-full h-full object-cover"
|
|
461
|
+
/>
|
|
462
|
+
{selectedImage?.id === image.id && (
|
|
463
|
+
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
|
|
464
|
+
<div className="bg-primary text-white rounded-full p-2">
|
|
465
|
+
<Check size={16} />
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
)}
|
|
469
|
+
</button>
|
|
470
|
+
))}
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
|
|
474
|
+
{/* Load More */}
|
|
475
|
+
{hasMore && !loading && (
|
|
476
|
+
<div className="text-center mt-4">
|
|
477
|
+
<button
|
|
478
|
+
onClick={() => loadImages(false)}
|
|
479
|
+
className="px-4 py-2 bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 rounded-lg text-sm font-bold hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
|
480
|
+
>
|
|
481
|
+
Load More
|
|
482
|
+
</button>
|
|
483
|
+
</div>
|
|
484
|
+
)}
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
{/* External URL Input in Modal */}
|
|
488
|
+
<div className="p-4 border-t border-neutral-200 dark:border-neutral-800">
|
|
489
|
+
<label className="text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase mb-2 block">
|
|
490
|
+
Or use external URL
|
|
491
|
+
</label>
|
|
492
|
+
<div className="flex gap-2">
|
|
493
|
+
<input
|
|
494
|
+
type="url"
|
|
495
|
+
value={externalUrl}
|
|
496
|
+
onChange={(e) => setExternalUrl(e.target.value)}
|
|
497
|
+
onKeyDown={(e) => {
|
|
498
|
+
if (e.key === 'Enter') {
|
|
499
|
+
handleUseExternalUrl();
|
|
500
|
+
}
|
|
501
|
+
}}
|
|
502
|
+
placeholder="https://example.com/image.jpg"
|
|
503
|
+
className="flex-1 px-3 py-2 bg-white dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-700 rounded-lg text-sm font-bold outline-none focus:border-primary transition-all dark:text-neutral-100"
|
|
504
|
+
/>
|
|
505
|
+
<button
|
|
506
|
+
onClick={handleUseExternalUrl}
|
|
507
|
+
className="px-4 py-2 bg-primary text-white rounded-lg text-sm font-bold hover:bg-primary/90 transition-colors flex items-center gap-2"
|
|
508
|
+
>
|
|
509
|
+
<LinkIcon size={16} />
|
|
510
|
+
Use URL
|
|
511
|
+
</button>
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
|
|
515
|
+
{/* Modal Footer */}
|
|
516
|
+
<div className="p-4 border-t border-neutral-200 dark:border-neutral-800 flex items-center justify-between">
|
|
517
|
+
<button
|
|
518
|
+
onClick={() => fileInputRef.current?.click()}
|
|
519
|
+
disabled={uploading}
|
|
520
|
+
className="px-4 py-2 bg-primary text-white rounded-xl text-sm font-bold hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
|
521
|
+
>
|
|
522
|
+
<Upload size={16} />
|
|
523
|
+
{uploading ? 'Uploading...' : 'Upload New Image'}
|
|
524
|
+
</button>
|
|
525
|
+
<button
|
|
526
|
+
onClick={() => {
|
|
527
|
+
setIsOpen(false);
|
|
528
|
+
setExternalUrl('');
|
|
529
|
+
}}
|
|
530
|
+
className="px-4 py-2 bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 rounded-xl text-sm font-bold hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
|
531
|
+
>
|
|
532
|
+
Close
|
|
533
|
+
</button>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
</div>
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Images Plugin Initialization Component
|
|
3
|
+
*
|
|
4
|
+
* This component reads from window.__JHITS_PLUGIN_PROPS__['plugin-images']
|
|
5
|
+
* and renders the GlobalImageEditor if enabled.
|
|
6
|
+
*
|
|
7
|
+
* Render this once in your app layout after calling initImagesPlugin().
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use client';
|
|
11
|
+
|
|
12
|
+
import { GlobalImageEditor } from './GlobalImageEditor';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Images Plugin Initialization Component
|
|
16
|
+
*
|
|
17
|
+
* Renders the global image editor if enabled in the plugin configuration.
|
|
18
|
+
* This component should be rendered in your app layout.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* import { ImagesPluginInit } from '@jhits/plugin-images';
|
|
23
|
+
*
|
|
24
|
+
* // After calling initImagesPlugin() in a useEffect or script
|
|
25
|
+
* <ImagesPluginInit />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function ImagesPluginInit() {
|
|
29
|
+
return <GlobalImageEditor />;
|
|
30
|
+
}
|
|
31
|
+
|