@jhits/plugin-images 0.0.13 → 0.0.14

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.
@@ -1,30 +1,793 @@
1
1
  /**
2
2
  * Image Manager View
3
- * Main view for managing uploaded images
3
+ * Full-featured image management with CRUD operations and usage tracking
4
4
  */
5
5
 
6
6
  'use client';
7
7
 
8
- import React from 'react';
8
+ import React, { useState, useEffect, useMemo, useCallback } from 'react';
9
+ import {
10
+ Plus,
11
+ Search,
12
+ Trash2,
13
+ Upload,
14
+ Image as ImageIcon,
15
+ Grid3x3,
16
+ List,
17
+ Loader2,
18
+ AlertTriangle,
19
+ CheckCircle,
20
+ ExternalLink,
21
+ X,
22
+ RefreshCw,
23
+ Eye,
24
+ HardDrive,
25
+ CheckCircle2,
26
+ Circle,
27
+ Trash
28
+ } from 'lucide-react';
29
+ import Image from 'next/image';
30
+
31
+ interface ImageMetadata {
32
+ id: string;
33
+ filename: string;
34
+ url: string;
35
+ size: number;
36
+ mimeType: string;
37
+ uploadedAt: string;
38
+ }
39
+
40
+ interface ImageUsage {
41
+ imageId: string;
42
+ filename: string;
43
+ usage: Array<{
44
+ plugin: string;
45
+ type: string;
46
+ title: string;
47
+ id: string;
48
+ url?: string;
49
+ }>;
50
+ }
51
+
52
+ interface StorageStats {
53
+ totalImages: number;
54
+ totalSize: number;
55
+ usedImages: number;
56
+ availableImages: number;
57
+ }
9
58
 
10
59
  export interface ImageManagerViewProps {
11
60
  siteId: string;
12
61
  locale: string;
13
62
  }
14
63
 
64
+ function formatFileSize(bytes: number): string {
65
+ if (bytes === 0) return '0 B';
66
+ const k = 1024;
67
+ const sizes = ['B', 'KB', 'MB', 'GB'];
68
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
69
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
70
+ }
71
+
72
+ function formatDate(dateString: string): string {
73
+ const date = new Date(dateString);
74
+ return date.toLocaleDateString('en-US', {
75
+ year: 'numeric',
76
+ month: 'short',
77
+ day: 'numeric',
78
+ hour: '2-digit',
79
+ minute: '2-digit'
80
+ });
81
+ }
82
+
15
83
  export function ImageManagerView({ siteId, locale }: ImageManagerViewProps) {
84
+ const [images, setImages] = useState<ImageMetadata[]>([]);
85
+ const [usageData, setUsageData] = useState<Record<string, ImageUsage['usage']>>({});
86
+ const [stats, setStats] = useState<StorageStats | null>(null);
87
+ const [mappedImages, setMappedImages] = useState<Set<string>>(new Set());
88
+ const [loading, setLoading] = useState(true);
89
+ const [search, setSearch] = useState('');
90
+ const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
91
+ const [uploading, setUploading] = useState(false);
92
+ const [selectedImage, setSelectedImage] = useState<ImageMetadata | null>(null);
93
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
94
+ const [showCleanupModal, setShowCleanupModal] = useState(false);
95
+ const [deleting, setDeleting] = useState(false);
96
+ const [cleaning, setCleaning] = useState(false);
97
+ const [refreshKey, setRefreshKey] = useState(0);
98
+
99
+ const fetchImages = useCallback(async () => {
100
+ try {
101
+ setLoading(true);
102
+ const response = await fetch('/api/plugin-images/list');
103
+ const data = await response.json();
104
+
105
+ if (data.images) {
106
+ setImages(data.images);
107
+ setStats(data.stats || null);
108
+ setMappedImages(new Set(data.mappedImages || []));
109
+ }
110
+ } catch (error) {
111
+ console.error('Failed to fetch images:', error);
112
+ } finally {
113
+ setLoading(false);
114
+ }
115
+ }, []);
116
+
117
+ const fetchUsageData = useCallback(async () => {
118
+ try {
119
+ const response = await fetch('/api/plugin-images/usage');
120
+ const data = await response.json();
121
+
122
+ if (data.images) {
123
+ const usageMap: Record<string, ImageUsage['usage']> = {};
124
+ data.images.forEach((img: ImageUsage) => {
125
+ usageMap[img.filename] = img.usage;
126
+ });
127
+ setUsageData(usageMap);
128
+ }
129
+ } catch (error) {
130
+ console.error('Failed to fetch usage data:', error);
131
+ }
132
+ }, []);
133
+
134
+ useEffect(() => {
135
+ fetchImages();
136
+ fetchUsageData();
137
+ }, [fetchImages, fetchUsageData, refreshKey]);
138
+
139
+ const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
140
+ const files = e.target.files;
141
+ if (!files || files.length === 0) return;
142
+
143
+ setUploading(true);
144
+
145
+ try {
146
+ for (const file of Array.from(files)) {
147
+ const formData = new FormData();
148
+ formData.append('file', file);
149
+
150
+ const response = await fetch('/api/plugin-images/upload', {
151
+ method: 'POST',
152
+ body: formData,
153
+ });
154
+
155
+ const data = await response.json();
156
+
157
+ if (data.success && data.image) {
158
+ setImages(prev => [data.image, ...prev]);
159
+ }
160
+ }
161
+ } catch (error) {
162
+ console.error('Upload failed:', error);
163
+ } finally {
164
+ setUploading(false);
165
+ if (e.target) e.target.value = '';
166
+ }
167
+ };
168
+
169
+ const handleDelete = async () => {
170
+ if (!selectedImage) return;
171
+
172
+ setDeleting(true);
173
+
174
+ try {
175
+ const response = await fetch(`/api/plugin-images/uploads/${selectedImage.filename}`, {
176
+ method: 'DELETE',
177
+ });
178
+
179
+ if (response.ok) {
180
+ setImages(prev => prev.filter(img => img.id !== selectedImage.id));
181
+ setShowDeleteModal(false);
182
+ setSelectedImage(null);
183
+ } else {
184
+ const error = await response.json();
185
+ alert(error.error || 'Failed to delete image');
186
+ }
187
+ } catch (error) {
188
+ console.error('Delete failed:', error);
189
+ alert('Failed to delete image');
190
+ } finally {
191
+ setDeleting(false);
192
+ }
193
+ };
194
+
195
+ const handleCleanup = async () => {
196
+ setCleaning(true);
197
+
198
+ try {
199
+ const unusedImages = images.filter(img => !isImageInUse(img.filename));
200
+ let deletedCount = 0;
201
+ let failedCount = 0;
202
+
203
+ for (const image of unusedImages) {
204
+ try {
205
+ const response = await fetch(`/api/plugin-images/uploads/${image.filename}`, {
206
+ method: 'DELETE',
207
+ });
208
+ if (response.ok) {
209
+ deletedCount++;
210
+ } else {
211
+ failedCount++;
212
+ }
213
+ } catch {
214
+ failedCount++;
215
+ }
216
+ }
217
+
218
+ if (deletedCount > 0) {
219
+ setImages(prev => prev.filter(img => isImageInUse(img.filename)));
220
+ }
221
+
222
+ setShowCleanupModal(false);
223
+
224
+ if (failedCount > 0) {
225
+ alert(`Deleted ${deletedCount} images. ${failedCount} failed.`);
226
+ } else {
227
+ alert(`Successfully deleted ${deletedCount} unused image${deletedCount !== 1 ? 's' : ''}.`);
228
+ }
229
+ } catch (error) {
230
+ console.error('Cleanup failed:', error);
231
+ alert('Failed to cleanup images');
232
+ } finally {
233
+ setCleaning(false);
234
+ }
235
+ };
236
+
237
+ const getUnusedImagesCount = (): number => {
238
+ return images.filter(img => !isImageInUse(img.filename)).length;
239
+ };
240
+
241
+ const handleRefresh = () => {
242
+ setRefreshKey(prev => prev + 1);
243
+ };
244
+
245
+ const filteredImages = useMemo(() => {
246
+ return images.filter(img =>
247
+ search === '' ||
248
+ img.filename.toLowerCase().includes(search.toLowerCase())
249
+ );
250
+ }, [images, search]);
251
+
252
+ const getImageUsage = (filename: string): ImageUsage['usage'] => {
253
+ return usageData[filename] || [];
254
+ };
255
+
256
+ const isImageMapped = (filename: string): boolean => {
257
+ return mappedImages.has(filename);
258
+ };
259
+
260
+ const isImageInUse = (filename: string): boolean => {
261
+ return isImageMapped(filename) || getImageUsage(filename).length > 0;
262
+ };
263
+
16
264
  return (
17
- <div className="min-h-screen bg-dashboard-bg p-8">
18
- <div className="max-w-7xl mx-auto">
19
- <h1 className="text-4xl font-black uppercase tracking-tighter text-dashboard-text mb-8">
20
- Image Manager
21
- </h1>
22
- <p className="text-sm text-neutral-600 dark:text-neutral-400 mb-8">
23
- This plugin provides image upload and management functionality.
24
- Use the ImagePicker component in other plugins to select images.
25
- </p>
265
+ <div className="h-full w-full rounded-[2.5rem] bg-white dark:bg-neutral-900 p-8 overflow-y-auto">
266
+ {/* Header Section */}
267
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
268
+ <div>
269
+ <h1 className="text-3xl font-black text-neutral-950 dark:text-white uppercase tracking-tighter mb-2">
270
+ Images
271
+ </h1>
272
+ <p className="text-sm text-neutral-500 dark:text-neutral-400">
273
+ Upload and manage your media library
274
+ </p>
275
+ </div>
276
+
277
+ <div className="flex items-center gap-3">
278
+ {stats && stats.availableImages > 0 && (
279
+ <button
280
+ onClick={() => setShowCleanupModal(true)}
281
+ className="inline-flex items-center gap-2 px-4 py-3 bg-red-500/10 text-red-600 dark:text-red-400 rounded-full text-[10px] font-bold uppercase tracking-widest hover:bg-red-500/20 transition-all"
282
+ title={`Remove ${stats.availableImages} unused images`}
283
+ >
284
+ <Trash size={16} />
285
+ Clean Up
286
+ </button>
287
+ )}
288
+
289
+ <label className="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-full text-[10px] font-black uppercase tracking-widest hover:bg-primary/90 transition-all shadow-lg shadow-primary/20 cursor-pointer">
290
+ {uploading ? (
291
+ <Loader2 size={16} className="animate-spin" />
292
+ ) : (
293
+ <Upload size={16} />
294
+ )}
295
+ {uploading ? 'Uploading...' : 'Upload'}
296
+ <input
297
+ type="file"
298
+ accept="image/*"
299
+ multiple
300
+ onChange={handleUpload}
301
+ className="hidden"
302
+ disabled={uploading}
303
+ />
304
+ </label>
305
+
306
+ <button
307
+ onClick={handleRefresh}
308
+ className="p-3 bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
309
+ title="Refresh"
310
+ >
311
+ <RefreshCw size={16} />
312
+ </button>
313
+ </div>
314
+ </div>
315
+
316
+ {/* Stats Cards */}
317
+ {stats && (
318
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
319
+ <div className="bg-neutral-100 dark:bg-neutral-800 rounded-2xl p-5 border border-neutral-200 dark:border-neutral-700">
320
+ <div className="flex items-center gap-3 mb-2">
321
+ <div className="p-2 bg-primary/10 rounded-xl">
322
+ <ImageIcon className="text-primary" size={20} />
323
+ </div>
324
+ <span className="text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Total Images</span>
325
+ </div>
326
+ <p className="text-2xl font-black text-neutral-900 dark:text-white">{stats.totalImages}</p>
327
+ </div>
328
+
329
+ <div className="bg-neutral-100 dark:bg-neutral-800 rounded-2xl p-5 border border-neutral-200 dark:border-neutral-700">
330
+ <div className="flex items-center gap-3 mb-2">
331
+ <div className="p-2 bg-amber-500/10 rounded-xl">
332
+ <HardDrive className="text-amber-500" size={20} />
333
+ </div>
334
+ <span className="text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Storage Used</span>
335
+ </div>
336
+ <p className="text-2xl font-black text-neutral-900 dark:text-white">{formatFileSize(stats.totalSize)}</p>
337
+ </div>
338
+
339
+ <div className="bg-neutral-100 dark:bg-neutral-800 rounded-2xl p-5 border border-neutral-200 dark:border-neutral-700">
340
+ <div className="flex items-center gap-3 mb-2">
341
+ <div className="p-2 bg-red-500/10 rounded-xl">
342
+ <CheckCircle2 className="text-red-500" size={20} />
343
+ </div>
344
+ <span className="text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">In Use</span>
345
+ </div>
346
+ <p className="text-2xl font-black text-neutral-900 dark:text-white">{stats.usedImages}</p>
347
+ </div>
348
+
349
+ <div className="bg-neutral-100 dark:bg-neutral-800 rounded-2xl p-5 border border-neutral-200 dark:border-neutral-700">
350
+ <div className="flex items-center gap-3 mb-2">
351
+ <div className="p-2 bg-green-500/10 rounded-xl">
352
+ <Circle className="text-green-500" size={20} />
353
+ </div>
354
+ <span className="text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Available</span>
355
+ </div>
356
+ <p className="text-2xl font-black text-neutral-900 dark:text-white">{stats.availableImages}</p>
357
+ </div>
358
+ </div>
359
+ )}
360
+
361
+ {/* Filters & Search Bar with View Toggle */}
362
+ <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
363
+ <div className="relative flex-1 max-w-md">
364
+ <Search className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-400" size={18} />
365
+ <input
366
+ type="text"
367
+ placeholder="Search images..."
368
+ value={search}
369
+ onChange={(e) => setSearch(e.target.value)}
370
+ className="w-full pl-12 pr-4 py-3 bg-neutral-100 dark:bg-neutral-800/50 border border-neutral-300 dark:border-neutral-700 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
371
+ />
372
+ </div>
373
+
374
+ <div className="flex items-center gap-2 bg-neutral-100 dark:bg-neutral-800/50 rounded-full p-1 border border-neutral-300 dark:border-neutral-700">
375
+ <button
376
+ onClick={() => setViewMode('grid')}
377
+ className={`p-2 rounded-full transition-all ${
378
+ viewMode === 'grid'
379
+ ? 'bg-white dark:bg-neutral-900 text-primary shadow-sm'
380
+ : 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'
381
+ }`}
382
+ title="Grid View"
383
+ >
384
+ <Grid3x3 size={18} />
385
+ </button>
386
+ <button
387
+ onClick={() => setViewMode('list')}
388
+ className={`p-2 rounded-full transition-all ${
389
+ viewMode === 'list'
390
+ ? 'bg-white dark:bg-neutral-900 text-primary shadow-sm'
391
+ : 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'
392
+ }`}
393
+ title="List View"
394
+ >
395
+ <List size={18} />
396
+ </button>
397
+ </div>
26
398
  </div>
399
+
400
+ {/* Content */}
401
+ {loading ? (
402
+ <div className="flex items-center justify-center py-20">
403
+ <Loader2 size={32} className="animate-spin text-primary" />
404
+ </div>
405
+ ) : filteredImages.length === 0 ? (
406
+ <div className="flex flex-col items-center justify-center py-20 text-center">
407
+ <div className="w-24 h-24 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center mb-6">
408
+ <ImageIcon size={40} className="text-neutral-400" />
409
+ </div>
410
+ <h3 className="text-xl font-bold text-neutral-600 dark:text-neutral-400 mb-2">
411
+ {search ? 'No images found' : 'No images yet'}
412
+ </h3>
413
+ <p className="text-neutral-500 dark:text-neutral-500 mb-6">
414
+ {search ? 'Try a different search term' : 'Upload your first image to get started'}
415
+ </p>
416
+ {!search && (
417
+ <label className="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-full text-[10px] font-black uppercase tracking-widest hover:bg-primary/90 transition-all shadow-lg shadow-primary/20 cursor-pointer">
418
+ <Upload size={16} />
419
+ Upload Image
420
+ <input
421
+ type="file"
422
+ accept="image/*"
423
+ onChange={handleUpload}
424
+ className="hidden"
425
+ />
426
+ </label>
427
+ )}
428
+ </div>
429
+ ) : viewMode === 'grid' ? (
430
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
431
+ {filteredImages.map((image) => {
432
+ const usage = getImageUsage(image.filename);
433
+ const inUse = isImageInUse(image.filename);
434
+
435
+ return (
436
+ <div
437
+ key={image.id}
438
+ className={`group relative bg-neutral-100 dark:bg-neutral-800 rounded-2xl overflow-hidden border transition-all hover:shadow-lg ${
439
+ inUse
440
+ ? 'border-amber-300 dark:border-amber-700'
441
+ : 'border-neutral-200 dark:border-neutral-700 hover:border-primary/50'
442
+ }`}
443
+ >
444
+ <div className="aspect-square relative bg-neutral-200 dark:bg-neutral-700">
445
+ <Image
446
+ src={image.url}
447
+ alt={image.filename}
448
+ fill
449
+ className="object-cover"
450
+ unoptimized
451
+ />
452
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
453
+ <button
454
+ onClick={() => window.open(image.url, '_blank')}
455
+ className="p-2 bg-white dark:bg-neutral-900 rounded-full text-primary hover:bg-primary/10 transition-colors"
456
+ title="View full size"
457
+ >
458
+ <Eye size={16} />
459
+ </button>
460
+ <button
461
+ onClick={() => {
462
+ setSelectedImage(image);
463
+ setShowDeleteModal(true);
464
+ }}
465
+ className={`p-2 bg-white dark:bg-neutral-900 rounded-full transition-colors ${
466
+ inUse
467
+ ? 'text-neutral-400 cursor-not-allowed'
468
+ : 'text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20'
469
+ }`}
470
+ title={inUse ? 'Cannot delete - image is in use' : 'Delete'}
471
+ disabled={inUse}
472
+ >
473
+ <Trash2 size={16} />
474
+ </button>
475
+ </div>
476
+ {inUse && (
477
+ <div className="absolute top-2 right-2 px-2 py-1 bg-red-500 text-white text-[10px] font-bold uppercase rounded-full flex items-center gap-1 shadow-lg">
478
+ <CheckCircle size={10} />
479
+ In Use
480
+ </div>
481
+ )}
482
+ </div>
483
+ <div className="p-3">
484
+ <p className="text-xs font-medium text-neutral-700 dark:text-neutral-300 truncate" title={image.filename}>
485
+ {image.filename}
486
+ </p>
487
+ <div className="flex items-center justify-between mt-1">
488
+ <p className="text-[10px] text-neutral-500">
489
+ {formatFileSize(image.size)}
490
+ </p>
491
+ {inUse ? (
492
+ <p className="text-[10px] text-red-500 dark:text-red-400 font-bold">
493
+ {usage.length > 0 ? `${usage.length} ref${usage.length > 1 ? 's' : ''}` : 'mapped'}
494
+ </p>
495
+ ) : (
496
+ <p className="text-[10px] text-green-500 dark:text-green-400 font-bold">
497
+ available
498
+ </p>
499
+ )}
500
+ </div>
501
+ </div>
502
+ </div>
503
+ );
504
+ })}
505
+ </div>
506
+ ) : (
507
+ <div className="bg-neutral-100 dark:bg-neutral-800 rounded-2xl overflow-hidden border border-neutral-200 dark:border-neutral-700">
508
+ <table className="w-full">
509
+ <thead className="bg-neutral-200 dark:bg-neutral-700/50">
510
+ <tr>
511
+ <th className="text-left px-4 py-3 text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase">Preview</th>
512
+ <th className="text-left px-4 py-3 text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase">Filename</th>
513
+ <th className="text-left px-4 py-3 text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase">Size</th>
514
+ <th className="text-left px-4 py-3 text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase">Uploaded</th>
515
+ <th className="text-left px-4 py-3 text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase">Status</th>
516
+ <th className="text-right px-4 py-3 text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase">Actions</th>
517
+ </tr>
518
+ </thead>
519
+ <tbody className="divide-y divide-neutral-200 dark:divide-neutral-700">
520
+ {filteredImages.map((image) => {
521
+ const usage = getImageUsage(image.filename);
522
+ const inUse = isImageInUse(image.filename);
523
+
524
+ return (
525
+ <tr key={image.id} className={`transition-colors ${
526
+ inUse
527
+ ? 'bg-amber-50/30 dark:bg-amber-900/5 hover:bg-amber-50/50 dark:hover:bg-amber-900/10'
528
+ : 'hover:bg-neutral-200/50 dark:hover:bg-neutral-700/30'
529
+ }`}>
530
+ <td className="px-4 py-3">
531
+ <div className={`w-12 h-12 relative bg-neutral-200 dark:bg-neutral-700 rounded-lg overflow-hidden ring-2 ${
532
+ inUse ? 'ring-red-400 dark:ring-red-600' : 'ring-transparent'
533
+ }`}>
534
+ <Image
535
+ src={image.url}
536
+ alt={image.filename}
537
+ fill
538
+ className="object-cover"
539
+ unoptimized
540
+ />
541
+ </div>
542
+ </td>
543
+ <td className="px-4 py-3">
544
+ <p className="text-sm font-medium text-neutral-700 dark:text-neutral-300 truncate max-w-[200px]" title={image.filename}>
545
+ {image.filename}
546
+ </p>
547
+ </td>
548
+ <td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
549
+ {formatFileSize(image.size)}
550
+ </td>
551
+ <td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
552
+ {formatDate(image.uploadedAt)}
553
+ </td>
554
+ <td className="px-4 py-3">
555
+ {inUse ? (
556
+ <span className="inline-flex items-center gap-1 px-2 py-1 bg-red-500/10 text-red-600 dark:text-red-400 text-xs font-bold rounded-full">
557
+ <CheckCircle size={12} />
558
+ In Use{usage.length > 0 ? ` (${usage.length})` : ''}
559
+ </span>
560
+ ) : (
561
+ <span className="inline-flex items-center gap-1 px-2 py-1 bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium rounded-full">
562
+ <Circle size={12} />
563
+ Available
564
+ </span>
565
+ )}
566
+ </td>
567
+ <td className="px-4 py-3 text-right">
568
+ <div className="flex items-center justify-end gap-2">
569
+ <button
570
+ onClick={() => window.open(image.url, '_blank')}
571
+ className="p-2 text-neutral-500 hover:text-primary hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded-lg transition-colors"
572
+ title="View"
573
+ >
574
+ <Eye size={16} />
575
+ </button>
576
+ <button
577
+ onClick={() => {
578
+ setSelectedImage(image);
579
+ setShowDeleteModal(true);
580
+ }}
581
+ className={`p-2 rounded-lg transition-colors ${
582
+ inUse
583
+ ? 'text-neutral-400 cursor-not-allowed'
584
+ : 'text-neutral-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20'
585
+ }`}
586
+ title={inUse ? 'Cannot delete - image is in use' : 'Delete'}
587
+ disabled={inUse}
588
+ >
589
+ <Trash2 size={16} />
590
+ </button>
591
+ </div>
592
+ </td>
593
+ </tr>
594
+ );
595
+ })}
596
+ </tbody>
597
+ </table>
598
+ </div>
599
+ )}
600
+
601
+ {/* Delete Confirmation Modal */}
602
+ {showDeleteModal && selectedImage && (
603
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
604
+ <div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 max-w-md w-full mx-4 shadow-2xl">
605
+ <div className="flex items-center justify-between mb-4">
606
+ <h3 className="text-xl font-bold text-neutral-900 dark:text-white">Delete Image</h3>
607
+ <button
608
+ onClick={() => {
609
+ setShowDeleteModal(false);
610
+ setSelectedImage(null);
611
+ }}
612
+ className="p-2 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 rounded-lg"
613
+ >
614
+ <X size={20} />
615
+ </button>
616
+ </div>
617
+
618
+ {(() => {
619
+ const usage = getImageUsage(selectedImage.filename);
620
+ const inUse = isImageInUse(selectedImage.filename);
621
+ const isMapped = isImageMapped(selectedImage.filename);
622
+
623
+ return (
624
+ <>
625
+ {inUse ? (
626
+ <div className="mb-6">
627
+ <div className="flex items-center gap-2 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl mb-4">
628
+ <AlertTriangle className="text-red-500 shrink-0" size={24} />
629
+ <div>
630
+ <p className="text-sm font-medium text-red-800 dark:text-red-200">
631
+ {isMapped && usage.length === 0
632
+ ? 'This image is mapped to content'
633
+ : 'This image is currently in use'}
634
+ </p>
635
+ <p className="text-xs text-red-600 dark:text-red-400 mt-1">
636
+ {isMapped && usage.length === 0
637
+ ? 'The image is linked via semantic ID mapping.'
638
+ : 'Please remove the image from all content before deleting.'}
639
+ </p>
640
+ </div>
641
+ </div>
642
+
643
+ {(usage.length > 0) && (
644
+ <div className="space-y-2 max-h-[200px] overflow-y-auto">
645
+ <p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Used in:</p>
646
+ {usage.map((item, idx) => (
647
+ <div key={idx} className="flex items-center justify-between p-3 bg-neutral-100 dark:bg-neutral-800 rounded-lg">
648
+ <div>
649
+ <p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{item.title}</p>
650
+ <p className="text-xs text-neutral-500">{item.plugin} - {item.type}</p>
651
+ </div>
652
+ {item.url && (
653
+ <a
654
+ href={item.url}
655
+ target="_blank"
656
+ rel="noopener noreferrer"
657
+ className="p-2 text-primary hover:bg-primary/10 rounded-lg transition-colors"
658
+ >
659
+ <ExternalLink size={16} />
660
+ </a>
661
+ )}
662
+ </div>
663
+ ))}
664
+ </div>
665
+ )}
666
+
667
+ {isMapped && usage.length === 0 && (
668
+ <p className="text-sm text-neutral-600 dark:text-neutral-400 mt-2">
669
+ This image is being used through semantic ID mappings. Delete the mapping first.
670
+ </p>
671
+ )}
672
+ </div>
673
+ ) : (
674
+ <div className="mb-6">
675
+ <div className="flex items-center gap-4 mb-4">
676
+ <div className="w-20 h-20 relative bg-neutral-200 dark:bg-neutral-700 rounded-lg overflow-hidden shrink-0">
677
+ <Image
678
+ src={selectedImage.url}
679
+ alt={selectedImage.filename}
680
+ fill
681
+ className="object-cover"
682
+ unoptimized
683
+ />
684
+ </div>
685
+ <div>
686
+ <p className="font-medium text-neutral-900 dark:text-white">{selectedImage.filename}</p>
687
+ <p className="text-sm text-neutral-500">{formatFileSize(selectedImage.size)}</p>
688
+ <p className="text-sm text-neutral-500">{formatDate(selectedImage.uploadedAt)}</p>
689
+ </div>
690
+ </div>
691
+ <p className="text-sm text-neutral-600 dark:text-neutral-400">
692
+ Are you sure you want to delete this image? This action cannot be undone.
693
+ </p>
694
+ </div>
695
+ )}
696
+
697
+ <div className="flex justify-end gap-3">
698
+ <button
699
+ onClick={() => {
700
+ setShowDeleteModal(false);
701
+ setSelectedImage(null);
702
+ }}
703
+ className="px-4 py-2 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
704
+ >
705
+ Cancel
706
+ </button>
707
+ {!inUse && (
708
+ <button
709
+ onClick={handleDelete}
710
+ disabled={deleting}
711
+ className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 flex items-center gap-2"
712
+ >
713
+ {deleting && <Loader2 size={16} className="animate-spin" />}
714
+ Delete
715
+ </button>
716
+ )}
717
+ </div>
718
+ </>
719
+ );
720
+ })()}
721
+ </div>
722
+ </div>
723
+ )}
724
+
725
+ {/* Cleanup Confirmation Modal */}
726
+ {showCleanupModal && (
727
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
728
+ <div className="bg-white dark:bg-neutral-900 rounded-2xl p-6 max-w-md w-full mx-4 shadow-2xl">
729
+ <div className="flex items-center justify-between mb-4">
730
+ <h3 className="text-xl font-bold text-neutral-900 dark:text-white">Clean Up Unused Images</h3>
731
+ <button
732
+ onClick={() => setShowCleanupModal(false)}
733
+ className="p-2 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 rounded-lg"
734
+ >
735
+ <X size={20} />
736
+ </button>
737
+ </div>
738
+
739
+ <div className="mb-6">
740
+ <div className="flex items-center gap-2 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl mb-4">
741
+ <AlertTriangle className="text-amber-500 shrink-0" size={24} />
742
+ <div>
743
+ <p className="text-sm font-medium text-amber-800 dark:text-amber-200">
744
+ Warning: This action cannot be undone
745
+ </p>
746
+ <p className="text-xs text-amber-600 dark:text-amber-400 mt-1">
747
+ {getUnusedImagesCount()} unused image{getUnusedImagesCount() !== 1 ? 's' : ''} will be permanently deleted.
748
+ </p>
749
+ </div>
750
+ </div>
751
+
752
+ <p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
753
+ This will delete all images that are not currently in use by any content (blogs, newsletters, or user avatars).
754
+ </p>
755
+
756
+ <div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-lg">
757
+ <div className="flex items-center justify-between text-sm">
758
+ <span className="text-neutral-600 dark:text-neutral-400">Total images:</span>
759
+ <span className="font-bold text-neutral-900 dark:text-white">{stats?.totalImages || 0}</span>
760
+ </div>
761
+ <div className="flex items-center justify-between text-sm mt-2">
762
+ <span className="text-neutral-600 dark:text-neutral-400">In use:</span>
763
+ <span className="font-bold text-red-500">{stats?.usedImages || 0}</span>
764
+ </div>
765
+ <div className="flex items-center justify-between text-sm mt-2">
766
+ <span className="text-neutral-600 dark:text-neutral-400">Will be deleted:</span>
767
+ <span className="font-bold text-red-500">{getUnusedImagesCount()}</span>
768
+ </div>
769
+ </div>
770
+ </div>
771
+
772
+ <div className="flex justify-end gap-3">
773
+ <button
774
+ onClick={() => setShowCleanupModal(false)}
775
+ className="px-4 py-2 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
776
+ >
777
+ Cancel
778
+ </button>
779
+ <button
780
+ onClick={handleCleanup}
781
+ disabled={cleaning}
782
+ className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 flex items-center gap-2"
783
+ >
784
+ {cleaning && <Loader2 size={16} className="animate-spin" />}
785
+ {cleaning ? 'Deleting...' : `Delete ${getUnusedImagesCount()} Images`}
786
+ </button>
787
+ </div>
788
+ </div>
789
+ </div>
790
+ )}
27
791
  </div>
28
792
  );
29
793
  }
30
-