@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,9 +1,237 @@
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
  'use client';
6
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
7
+ import { useState, useEffect, useMemo, useCallback } from 'react';
8
+ import { Search, Trash2, Upload, Image as ImageIcon, Grid3x3, List, Loader2, AlertTriangle, CheckCircle, ExternalLink, X, RefreshCw, Eye, HardDrive, CheckCircle2, Circle, Trash } from 'lucide-react';
9
+ import Image from 'next/image';
10
+ function formatFileSize(bytes) {
11
+ if (bytes === 0)
12
+ return '0 B';
13
+ const k = 1024;
14
+ const sizes = ['B', 'KB', 'MB', 'GB'];
15
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
16
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
17
+ }
18
+ function formatDate(dateString) {
19
+ const date = new Date(dateString);
20
+ return date.toLocaleDateString('en-US', {
21
+ year: 'numeric',
22
+ month: 'short',
23
+ day: 'numeric',
24
+ hour: '2-digit',
25
+ minute: '2-digit'
26
+ });
27
+ }
7
28
  export function ImageManagerView({ siteId, locale }) {
8
- return (_jsx("div", { className: "min-h-screen bg-dashboard-bg p-8", children: _jsxs("div", { className: "max-w-7xl mx-auto", children: [_jsx("h1", { className: "text-4xl font-black uppercase tracking-tighter text-dashboard-text mb-8", children: "Image Manager" }), _jsx("p", { className: "text-sm text-neutral-600 dark:text-neutral-400 mb-8", children: "This plugin provides image upload and management functionality. Use the ImagePicker component in other plugins to select images." })] }) }));
29
+ const [images, setImages] = useState([]);
30
+ const [usageData, setUsageData] = useState({});
31
+ const [stats, setStats] = useState(null);
32
+ const [mappedImages, setMappedImages] = useState(new Set());
33
+ const [loading, setLoading] = useState(true);
34
+ const [search, setSearch] = useState('');
35
+ const [viewMode, setViewMode] = useState('grid');
36
+ const [uploading, setUploading] = useState(false);
37
+ const [selectedImage, setSelectedImage] = useState(null);
38
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
39
+ const [showCleanupModal, setShowCleanupModal] = useState(false);
40
+ const [deleting, setDeleting] = useState(false);
41
+ const [cleaning, setCleaning] = useState(false);
42
+ const [refreshKey, setRefreshKey] = useState(0);
43
+ const fetchImages = useCallback(async () => {
44
+ try {
45
+ setLoading(true);
46
+ const response = await fetch('/api/plugin-images/list');
47
+ const data = await response.json();
48
+ if (data.images) {
49
+ setImages(data.images);
50
+ setStats(data.stats || null);
51
+ setMappedImages(new Set(data.mappedImages || []));
52
+ }
53
+ }
54
+ catch (error) {
55
+ console.error('Failed to fetch images:', error);
56
+ }
57
+ finally {
58
+ setLoading(false);
59
+ }
60
+ }, []);
61
+ const fetchUsageData = useCallback(async () => {
62
+ try {
63
+ const response = await fetch('/api/plugin-images/usage');
64
+ const data = await response.json();
65
+ if (data.images) {
66
+ const usageMap = {};
67
+ data.images.forEach((img) => {
68
+ usageMap[img.filename] = img.usage;
69
+ });
70
+ setUsageData(usageMap);
71
+ }
72
+ }
73
+ catch (error) {
74
+ console.error('Failed to fetch usage data:', error);
75
+ }
76
+ }, []);
77
+ useEffect(() => {
78
+ fetchImages();
79
+ fetchUsageData();
80
+ }, [fetchImages, fetchUsageData, refreshKey]);
81
+ const handleUpload = async (e) => {
82
+ const files = e.target.files;
83
+ if (!files || files.length === 0)
84
+ return;
85
+ setUploading(true);
86
+ try {
87
+ for (const file of Array.from(files)) {
88
+ const formData = new FormData();
89
+ formData.append('file', file);
90
+ const response = await fetch('/api/plugin-images/upload', {
91
+ method: 'POST',
92
+ body: formData,
93
+ });
94
+ const data = await response.json();
95
+ if (data.success && data.image) {
96
+ setImages(prev => [data.image, ...prev]);
97
+ }
98
+ }
99
+ }
100
+ catch (error) {
101
+ console.error('Upload failed:', error);
102
+ }
103
+ finally {
104
+ setUploading(false);
105
+ if (e.target)
106
+ e.target.value = '';
107
+ }
108
+ };
109
+ const handleDelete = async () => {
110
+ if (!selectedImage)
111
+ return;
112
+ setDeleting(true);
113
+ try {
114
+ const response = await fetch(`/api/plugin-images/uploads/${selectedImage.filename}`, {
115
+ method: 'DELETE',
116
+ });
117
+ if (response.ok) {
118
+ setImages(prev => prev.filter(img => img.id !== selectedImage.id));
119
+ setShowDeleteModal(false);
120
+ setSelectedImage(null);
121
+ }
122
+ else {
123
+ const error = await response.json();
124
+ alert(error.error || 'Failed to delete image');
125
+ }
126
+ }
127
+ catch (error) {
128
+ console.error('Delete failed:', error);
129
+ alert('Failed to delete image');
130
+ }
131
+ finally {
132
+ setDeleting(false);
133
+ }
134
+ };
135
+ const handleCleanup = async () => {
136
+ setCleaning(true);
137
+ try {
138
+ const unusedImages = images.filter(img => !isImageInUse(img.filename));
139
+ let deletedCount = 0;
140
+ let failedCount = 0;
141
+ for (const image of unusedImages) {
142
+ try {
143
+ const response = await fetch(`/api/plugin-images/uploads/${image.filename}`, {
144
+ method: 'DELETE',
145
+ });
146
+ if (response.ok) {
147
+ deletedCount++;
148
+ }
149
+ else {
150
+ failedCount++;
151
+ }
152
+ }
153
+ catch {
154
+ failedCount++;
155
+ }
156
+ }
157
+ if (deletedCount > 0) {
158
+ setImages(prev => prev.filter(img => isImageInUse(img.filename)));
159
+ }
160
+ setShowCleanupModal(false);
161
+ if (failedCount > 0) {
162
+ alert(`Deleted ${deletedCount} images. ${failedCount} failed.`);
163
+ }
164
+ else {
165
+ alert(`Successfully deleted ${deletedCount} unused image${deletedCount !== 1 ? 's' : ''}.`);
166
+ }
167
+ }
168
+ catch (error) {
169
+ console.error('Cleanup failed:', error);
170
+ alert('Failed to cleanup images');
171
+ }
172
+ finally {
173
+ setCleaning(false);
174
+ }
175
+ };
176
+ const getUnusedImagesCount = () => {
177
+ return images.filter(img => !isImageInUse(img.filename)).length;
178
+ };
179
+ const handleRefresh = () => {
180
+ setRefreshKey(prev => prev + 1);
181
+ };
182
+ const filteredImages = useMemo(() => {
183
+ return images.filter(img => search === '' ||
184
+ img.filename.toLowerCase().includes(search.toLowerCase()));
185
+ }, [images, search]);
186
+ const getImageUsage = (filename) => {
187
+ return usageData[filename] || [];
188
+ };
189
+ const isImageMapped = (filename) => {
190
+ return mappedImages.has(filename);
191
+ };
192
+ const isImageInUse = (filename) => {
193
+ return isImageMapped(filename) || getImageUsage(filename).length > 0;
194
+ };
195
+ return (_jsxs("div", { className: "h-full w-full rounded-[2.5rem] bg-white dark:bg-neutral-900 p-8 overflow-y-auto", children: [_jsxs("div", { className: "flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-black text-neutral-950 dark:text-white uppercase tracking-tighter mb-2", children: "Images" }), _jsx("p", { className: "text-sm text-neutral-500 dark:text-neutral-400", children: "Upload and manage your media library" })] }), _jsxs("div", { className: "flex items-center gap-3", children: [stats && stats.availableImages > 0 && (_jsxs("button", { onClick: () => setShowCleanupModal(true), 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", title: `Remove ${stats.availableImages} unused images`, children: [_jsx(Trash, { size: 16 }), "Clean Up"] })), _jsxs("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", children: [uploading ? (_jsx(Loader2, { size: 16, className: "animate-spin" })) : (_jsx(Upload, { size: 16 })), uploading ? 'Uploading...' : 'Upload', _jsx("input", { type: "file", accept: "image/*", multiple: true, onChange: handleUpload, className: "hidden", disabled: uploading })] }), _jsx("button", { onClick: handleRefresh, 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", title: "Refresh", children: _jsx(RefreshCw, { size: 16 }) })] })] }), stats && (_jsxs("div", { className: "grid grid-cols-2 md:grid-cols-4 gap-4 mb-8", children: [_jsxs("div", { className: "bg-neutral-100 dark:bg-neutral-800 rounded-2xl p-5 border border-neutral-200 dark:border-neutral-700", children: [_jsxs("div", { className: "flex items-center gap-3 mb-2", children: [_jsx("div", { className: "p-2 bg-primary/10 rounded-xl", children: _jsx(ImageIcon, { className: "text-primary", size: 20 }) }), _jsx("span", { className: "text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider", children: "Total Images" })] }), _jsx("p", { className: "text-2xl font-black text-neutral-900 dark:text-white", children: stats.totalImages })] }), _jsxs("div", { className: "bg-neutral-100 dark:bg-neutral-800 rounded-2xl p-5 border border-neutral-200 dark:border-neutral-700", children: [_jsxs("div", { className: "flex items-center gap-3 mb-2", children: [_jsx("div", { className: "p-2 bg-amber-500/10 rounded-xl", children: _jsx(HardDrive, { className: "text-amber-500", size: 20 }) }), _jsx("span", { className: "text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider", children: "Storage Used" })] }), _jsx("p", { className: "text-2xl font-black text-neutral-900 dark:text-white", children: formatFileSize(stats.totalSize) })] }), _jsxs("div", { className: "bg-neutral-100 dark:bg-neutral-800 rounded-2xl p-5 border border-neutral-200 dark:border-neutral-700", children: [_jsxs("div", { className: "flex items-center gap-3 mb-2", children: [_jsx("div", { className: "p-2 bg-red-500/10 rounded-xl", children: _jsx(CheckCircle2, { className: "text-red-500", size: 20 }) }), _jsx("span", { className: "text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider", children: "In Use" })] }), _jsx("p", { className: "text-2xl font-black text-neutral-900 dark:text-white", children: stats.usedImages })] }), _jsxs("div", { className: "bg-neutral-100 dark:bg-neutral-800 rounded-2xl p-5 border border-neutral-200 dark:border-neutral-700", children: [_jsxs("div", { className: "flex items-center gap-3 mb-2", children: [_jsx("div", { className: "p-2 bg-green-500/10 rounded-xl", children: _jsx(Circle, { className: "text-green-500", size: 20 }) }), _jsx("span", { className: "text-xs font-bold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider", children: "Available" })] }), _jsx("p", { className: "text-2xl font-black text-neutral-900 dark:text-white", children: stats.availableImages })] })] })), _jsxs("div", { className: "flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6", children: [_jsxs("div", { className: "relative flex-1 max-w-md", children: [_jsx(Search, { className: "absolute left-4 top-1/2 -translate-y-1/2 text-neutral-400", size: 18 }), _jsx("input", { type: "text", placeholder: "Search images...", value: search, onChange: (e) => setSearch(e.target.value), 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" })] }), _jsxs("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", children: [_jsx("button", { onClick: () => setViewMode('grid'), className: `p-2 rounded-full transition-all ${viewMode === 'grid'
196
+ ? 'bg-white dark:bg-neutral-900 text-primary shadow-sm'
197
+ : 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'}`, title: "Grid View", children: _jsx(Grid3x3, { size: 18 }) }), _jsx("button", { onClick: () => setViewMode('list'), className: `p-2 rounded-full transition-all ${viewMode === 'list'
198
+ ? 'bg-white dark:bg-neutral-900 text-primary shadow-sm'
199
+ : 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'}`, title: "List View", children: _jsx(List, { size: 18 }) })] })] }), loading ? (_jsx("div", { className: "flex items-center justify-center py-20", children: _jsx(Loader2, { size: 32, className: "animate-spin text-primary" }) })) : filteredImages.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center py-20 text-center", children: [_jsx("div", { className: "w-24 h-24 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center mb-6", children: _jsx(ImageIcon, { size: 40, className: "text-neutral-400" }) }), _jsx("h3", { className: "text-xl font-bold text-neutral-600 dark:text-neutral-400 mb-2", children: search ? 'No images found' : 'No images yet' }), _jsx("p", { className: "text-neutral-500 dark:text-neutral-500 mb-6", children: search ? 'Try a different search term' : 'Upload your first image to get started' }), !search && (_jsxs("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", children: [_jsx(Upload, { size: 16 }), "Upload Image", _jsx("input", { type: "file", accept: "image/*", onChange: handleUpload, className: "hidden" })] }))] })) : viewMode === 'grid' ? (_jsx("div", { className: "grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4", children: filteredImages.map((image) => {
200
+ const usage = getImageUsage(image.filename);
201
+ const inUse = isImageInUse(image.filename);
202
+ return (_jsxs("div", { className: `group relative bg-neutral-100 dark:bg-neutral-800 rounded-2xl overflow-hidden border transition-all hover:shadow-lg ${inUse
203
+ ? 'border-amber-300 dark:border-amber-700'
204
+ : 'border-neutral-200 dark:border-neutral-700 hover:border-primary/50'}`, children: [_jsxs("div", { className: "aspect-square relative bg-neutral-200 dark:bg-neutral-700", children: [_jsx(Image, { src: image.url, alt: image.filename, fill: true, className: "object-cover", unoptimized: true }), _jsxs("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", children: [_jsx("button", { onClick: () => window.open(image.url, '_blank'), className: "p-2 bg-white dark:bg-neutral-900 rounded-full text-primary hover:bg-primary/10 transition-colors", title: "View full size", children: _jsx(Eye, { size: 16 }) }), _jsx("button", { onClick: () => {
205
+ setSelectedImage(image);
206
+ setShowDeleteModal(true);
207
+ }, className: `p-2 bg-white dark:bg-neutral-900 rounded-full transition-colors ${inUse
208
+ ? 'text-neutral-400 cursor-not-allowed'
209
+ : 'text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20'}`, title: inUse ? 'Cannot delete - image is in use' : 'Delete', disabled: inUse, children: _jsx(Trash2, { size: 16 }) })] }), inUse && (_jsxs("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", children: [_jsx(CheckCircle, { size: 10 }), "In Use"] }))] }), _jsxs("div", { className: "p-3", children: [_jsx("p", { className: "text-xs font-medium text-neutral-700 dark:text-neutral-300 truncate", title: image.filename, children: image.filename }), _jsxs("div", { className: "flex items-center justify-between mt-1", children: [_jsx("p", { className: "text-[10px] text-neutral-500", children: formatFileSize(image.size) }), inUse ? (_jsx("p", { className: "text-[10px] text-red-500 dark:text-red-400 font-bold", children: usage.length > 0 ? `${usage.length} ref${usage.length > 1 ? 's' : ''}` : 'mapped' })) : (_jsx("p", { className: "text-[10px] text-green-500 dark:text-green-400 font-bold", children: "available" }))] })] })] }, image.id));
210
+ }) })) : (_jsx("div", { className: "bg-neutral-100 dark:bg-neutral-800 rounded-2xl overflow-hidden border border-neutral-200 dark:border-neutral-700", children: _jsxs("table", { className: "w-full", children: [_jsx("thead", { className: "bg-neutral-200 dark:bg-neutral-700/50", children: _jsxs("tr", { children: [_jsx("th", { className: "text-left px-4 py-3 text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase", children: "Preview" }), _jsx("th", { className: "text-left px-4 py-3 text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase", children: "Filename" }), _jsx("th", { className: "text-left px-4 py-3 text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase", children: "Size" }), _jsx("th", { className: "text-left px-4 py-3 text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase", children: "Uploaded" }), _jsx("th", { className: "text-left px-4 py-3 text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase", children: "Status" }), _jsx("th", { className: "text-right px-4 py-3 text-xs font-bold text-neutral-600 dark:text-neutral-400 uppercase", children: "Actions" })] }) }), _jsx("tbody", { className: "divide-y divide-neutral-200 dark:divide-neutral-700", children: filteredImages.map((image) => {
211
+ const usage = getImageUsage(image.filename);
212
+ const inUse = isImageInUse(image.filename);
213
+ return (_jsxs("tr", { className: `transition-colors ${inUse
214
+ ? 'bg-amber-50/30 dark:bg-amber-900/5 hover:bg-amber-50/50 dark:hover:bg-amber-900/10'
215
+ : 'hover:bg-neutral-200/50 dark:hover:bg-neutral-700/30'}`, children: [_jsx("td", { className: "px-4 py-3", children: _jsx("div", { className: `w-12 h-12 relative bg-neutral-200 dark:bg-neutral-700 rounded-lg overflow-hidden ring-2 ${inUse ? 'ring-red-400 dark:ring-red-600' : 'ring-transparent'}`, children: _jsx(Image, { src: image.url, alt: image.filename, fill: true, className: "object-cover", unoptimized: true }) }) }), _jsx("td", { className: "px-4 py-3", children: _jsx("p", { className: "text-sm font-medium text-neutral-700 dark:text-neutral-300 truncate max-w-[200px]", title: image.filename, children: image.filename }) }), _jsx("td", { className: "px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400", children: formatFileSize(image.size) }), _jsx("td", { className: "px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400", children: formatDate(image.uploadedAt) }), _jsx("td", { className: "px-4 py-3", children: inUse ? (_jsxs("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", children: [_jsx(CheckCircle, { size: 12 }), "In Use", usage.length > 0 ? ` (${usage.length})` : ''] })) : (_jsxs("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", children: [_jsx(Circle, { size: 12 }), "Available"] })) }), _jsx("td", { className: "px-4 py-3 text-right", children: _jsxs("div", { className: "flex items-center justify-end gap-2", children: [_jsx("button", { onClick: () => window.open(image.url, '_blank'), className: "p-2 text-neutral-500 hover:text-primary hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded-lg transition-colors", title: "View", children: _jsx(Eye, { size: 16 }) }), _jsx("button", { onClick: () => {
216
+ setSelectedImage(image);
217
+ setShowDeleteModal(true);
218
+ }, className: `p-2 rounded-lg transition-colors ${inUse
219
+ ? 'text-neutral-400 cursor-not-allowed'
220
+ : 'text-neutral-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20'}`, title: inUse ? 'Cannot delete - image is in use' : 'Delete', disabled: inUse, children: _jsx(Trash2, { size: 16 }) })] }) })] }, image.id));
221
+ }) })] }) })), showDeleteModal && selectedImage && (_jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm", children: _jsxs("div", { className: "bg-white dark:bg-neutral-900 rounded-2xl p-6 max-w-md w-full mx-4 shadow-2xl", children: [_jsxs("div", { className: "flex items-center justify-between mb-4", children: [_jsx("h3", { className: "text-xl font-bold text-neutral-900 dark:text-white", children: "Delete Image" }), _jsx("button", { onClick: () => {
222
+ setShowDeleteModal(false);
223
+ setSelectedImage(null);
224
+ }, className: "p-2 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 rounded-lg", children: _jsx(X, { size: 20 }) })] }), (() => {
225
+ const usage = getImageUsage(selectedImage.filename);
226
+ const inUse = isImageInUse(selectedImage.filename);
227
+ const isMapped = isImageMapped(selectedImage.filename);
228
+ return (_jsxs(_Fragment, { children: [inUse ? (_jsxs("div", { className: "mb-6", children: [_jsxs("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", children: [_jsx(AlertTriangle, { className: "text-red-500 shrink-0", size: 24 }), _jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium text-red-800 dark:text-red-200", children: isMapped && usage.length === 0
229
+ ? 'This image is mapped to content'
230
+ : 'This image is currently in use' }), _jsx("p", { className: "text-xs text-red-600 dark:text-red-400 mt-1", children: isMapped && usage.length === 0
231
+ ? 'The image is linked via semantic ID mapping.'
232
+ : 'Please remove the image from all content before deleting.' })] })] }), (usage.length > 0) && (_jsxs("div", { className: "space-y-2 max-h-[200px] overflow-y-auto", children: [_jsx("p", { className: "text-sm font-medium text-neutral-700 dark:text-neutral-300", children: "Used in:" }), usage.map((item, idx) => (_jsxs("div", { className: "flex items-center justify-between p-3 bg-neutral-100 dark:bg-neutral-800 rounded-lg", children: [_jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium text-neutral-700 dark:text-neutral-300", children: item.title }), _jsxs("p", { className: "text-xs text-neutral-500", children: [item.plugin, " - ", item.type] })] }), item.url && (_jsx("a", { href: item.url, target: "_blank", rel: "noopener noreferrer", className: "p-2 text-primary hover:bg-primary/10 rounded-lg transition-colors", children: _jsx(ExternalLink, { size: 16 }) }))] }, idx)))] })), isMapped && usage.length === 0 && (_jsx("p", { className: "text-sm text-neutral-600 dark:text-neutral-400 mt-2", children: "This image is being used through semantic ID mappings. Delete the mapping first." }))] })) : (_jsxs("div", { className: "mb-6", children: [_jsxs("div", { className: "flex items-center gap-4 mb-4", children: [_jsx("div", { className: "w-20 h-20 relative bg-neutral-200 dark:bg-neutral-700 rounded-lg overflow-hidden shrink-0", children: _jsx(Image, { src: selectedImage.url, alt: selectedImage.filename, fill: true, className: "object-cover", unoptimized: true }) }), _jsxs("div", { children: [_jsx("p", { className: "font-medium text-neutral-900 dark:text-white", children: selectedImage.filename }), _jsx("p", { className: "text-sm text-neutral-500", children: formatFileSize(selectedImage.size) }), _jsx("p", { className: "text-sm text-neutral-500", children: formatDate(selectedImage.uploadedAt) })] })] }), _jsx("p", { className: "text-sm text-neutral-600 dark:text-neutral-400", children: "Are you sure you want to delete this image? This action cannot be undone." })] })), _jsxs("div", { className: "flex justify-end gap-3", children: [_jsx("button", { onClick: () => {
233
+ setShowDeleteModal(false);
234
+ setSelectedImage(null);
235
+ }, 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", children: "Cancel" }), !inUse && (_jsxs("button", { onClick: handleDelete, disabled: deleting, 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", children: [deleting && _jsx(Loader2, { size: 16, className: "animate-spin" }), "Delete"] }))] })] }));
236
+ })()] }) })), showCleanupModal && (_jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm", children: _jsxs("div", { className: "bg-white dark:bg-neutral-900 rounded-2xl p-6 max-w-md w-full mx-4 shadow-2xl", children: [_jsxs("div", { className: "flex items-center justify-between mb-4", children: [_jsx("h3", { className: "text-xl font-bold text-neutral-900 dark:text-white", children: "Clean Up Unused Images" }), _jsx("button", { onClick: () => setShowCleanupModal(false), className: "p-2 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 rounded-lg", children: _jsx(X, { size: 20 }) })] }), _jsxs("div", { className: "mb-6", children: [_jsxs("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", children: [_jsx(AlertTriangle, { className: "text-amber-500 shrink-0", size: 24 }), _jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium text-amber-800 dark:text-amber-200", children: "Warning: This action cannot be undone" }), _jsxs("p", { className: "text-xs text-amber-600 dark:text-amber-400 mt-1", children: [getUnusedImagesCount(), " unused image", getUnusedImagesCount() !== 1 ? 's' : '', " will be permanently deleted."] })] })] }), _jsx("p", { className: "text-sm text-neutral-600 dark:text-neutral-400 mb-4", children: "This will delete all images that are not currently in use by any content (blogs, newsletters, or user avatars)." }), _jsxs("div", { className: "p-3 bg-neutral-100 dark:bg-neutral-800 rounded-lg", children: [_jsxs("div", { className: "flex items-center justify-between text-sm", children: [_jsx("span", { className: "text-neutral-600 dark:text-neutral-400", children: "Total images:" }), _jsx("span", { className: "font-bold text-neutral-900 dark:text-white", children: stats?.totalImages || 0 })] }), _jsxs("div", { className: "flex items-center justify-between text-sm mt-2", children: [_jsx("span", { className: "text-neutral-600 dark:text-neutral-400", children: "In use:" }), _jsx("span", { className: "font-bold text-red-500", children: stats?.usedImages || 0 })] }), _jsxs("div", { className: "flex items-center justify-between text-sm mt-2", children: [_jsx("span", { className: "text-neutral-600 dark:text-neutral-400", children: "Will be deleted:" }), _jsx("span", { className: "font-bold text-red-500", children: getUnusedImagesCount() })] })] })] }), _jsxs("div", { className: "flex justify-end gap-3", children: [_jsx("button", { onClick: () => setShowCleanupModal(false), 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", children: "Cancel" }), _jsxs("button", { onClick: handleCleanup, disabled: cleaning, 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", children: [cleaning && _jsx(Loader2, { size: 16, className: "animate-spin" }), cleaning ? 'Deleting...' : `Delete ${getUnusedImagesCount()} Images`] })] })] }) }))] }));
9
237
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhits/plugin-images",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "Image management and storage plugin for the JHITS ecosystem",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -4,65 +4,183 @@
4
4
  */
5
5
 
6
6
  import { NextRequest, NextResponse } from 'next/server';
7
- import { readdir, stat } from 'fs/promises';
7
+ import { readdir, stat, readFile } from 'fs/promises';
8
8
  import path from 'path';
9
9
 
10
10
  const uploadsDir = path.join(process.cwd(), 'data', 'uploads');
11
+ const mappingsPath = path.join(process.cwd(), 'data', 'image-mappings.json');
12
+
13
+ interface BlogDoc {
14
+ _id: string;
15
+ slug?: string;
16
+ title?: string;
17
+ image?: { id?: string; src?: string };
18
+ contentBlocks?: any[];
19
+ }
20
+
21
+ interface UserDoc {
22
+ _id: string;
23
+ name?: string;
24
+ email?: string;
25
+ image?: string;
26
+ }
27
+
28
+ async function queryDatabase(): Promise<{ blogFilenames: Set<string>; userFilenames: Set<string> }> {
29
+ const blogFilenames = new Set<string>();
30
+ const userFilenames = new Set<string>();
31
+
32
+ try {
33
+ const { MongoClient } = await import('mongodb');
34
+ const uri = process.env.DATABASE_URL || process.env.MONGODB_URI;
35
+ if (!uri) return { blogFilenames, userFilenames };
36
+
37
+ const client = new MongoClient(uri);
38
+ await client.connect();
39
+ const db = client.db();
40
+
41
+ // Get blogs
42
+ const blogs = await db.collection('blogs').find({}).project({ image: 1, contentBlocks: 1 }).toArray() as BlogDoc[];
43
+
44
+ for (const blog of blogs) {
45
+ // Featured image
46
+ if (blog.image?.id) blogFilenames.add(blog.image.id);
47
+ if (blog.image?.src) blogFilenames.add(blog.image.src);
48
+
49
+ // Content blocks - recursively find image.src
50
+ if (blog.contentBlocks) {
51
+ const findImages = (obj: any) => {
52
+ if (!obj || typeof obj !== 'object') return;
53
+ if (Array.isArray(obj)) {
54
+ obj.forEach(findImages);
55
+ return;
56
+ }
57
+ if (obj.src && typeof obj.src === 'string' && !obj.src.startsWith('http')) {
58
+ blogFilenames.add(obj.src);
59
+ }
60
+ if (obj.children) findImages(obj.children);
61
+ if (obj.data) findImages(obj.data);
62
+ };
63
+ findImages(blog.contentBlocks);
64
+ }
65
+ }
66
+
67
+ // Get users with avatars
68
+ const users = await db.collection('users').find({ image: { $exists: true, $ne: null } }).project({ image: 1 }).toArray() as UserDoc[];
69
+ for (const user of users) {
70
+ if (user.image && user.image.includes('/api/uploads/')) {
71
+ const filename = user.image.split('/api/uploads/')[1];
72
+ if (filename) userFilenames.add(filename);
73
+ }
74
+ }
75
+
76
+ await client.close();
77
+ } catch (error) {
78
+ console.error('Error querying database for list:', error);
79
+ }
80
+
81
+ return { blogFilenames, userFilenames };
82
+ }
83
+
84
+ function extractMappings(): { mappedFilenames: Set<string>; semanticIdToFilename: Record<string, string> } {
85
+ const mappedFilenames = new Set<string>();
86
+ const semanticIdToFilename: Record<string, string> = {};
87
+
88
+ try {
89
+ const mappingsContent = require('fs').readFileSync(mappingsPath, 'utf-8');
90
+ const mappings = JSON.parse(mappingsContent);
91
+ Object.entries(mappings).forEach(([semanticId, mapping]) => {
92
+ const filename = typeof mapping === 'string' ? mapping : (mapping as any).filename;
93
+ if (filename) {
94
+ mappedFilenames.add(filename);
95
+ semanticIdToFilename[semanticId] = filename;
96
+ }
97
+ });
98
+ } catch {}
99
+
100
+ return { mappedFilenames, semanticIdToFilename };
101
+ }
11
102
 
12
103
  export async function GET(request: NextRequest) {
13
104
  try {
14
105
  const { searchParams } = new URL(request.url);
15
106
  const page = parseInt(searchParams.get('page') || '1');
16
- const limit = parseInt(searchParams.get('limit') || '20');
107
+ const limit = parseInt(searchParams.get('limit') || '1000');
17
108
  const search = searchParams.get('search') || '';
18
109
 
19
- // Read uploads directory
20
110
  let files: string[] = [];
111
+ let totalSize = 0;
112
+
21
113
  try {
22
114
  files = await readdir(uploadsDir);
23
115
  } catch (error) {
24
- // Directory doesn't exist yet
25
116
  return NextResponse.json({
26
117
  images: [],
27
118
  total: 0,
28
119
  page: 1,
29
120
  limit,
121
+ stats: {
122
+ totalImages: 0,
123
+ totalSize: 0,
124
+ usedImages: 0,
125
+ availableImages: 0,
126
+ }
30
127
  });
31
128
  }
32
129
 
33
- // Filter image files and get metadata
34
130
  const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif'];
35
131
  const imageFiles = files.filter(file => {
36
132
  const ext = path.extname(file).toLowerCase();
37
133
  return imageExtensions.includes(ext);
38
134
  });
39
135
 
40
- // Apply search filter if provided
41
- const filteredFiles = search
42
- ? imageFiles.filter(file => file.toLowerCase().includes(search.toLowerCase()))
43
- : imageFiles;
44
-
45
- // Sort by modification time (newest first)
46
136
  const filesWithStats = await Promise.all(
47
- filteredFiles.map(async (filename) => {
137
+ imageFiles.map(async (filename) => {
48
138
  const filePath = path.join(uploadsDir, filename);
49
- const stats = await stat(filePath);
50
- return {
51
- filename,
52
- mtime: stats.mtime,
53
- size: stats.size,
54
- };
139
+ try {
140
+ const stats = await stat(filePath);
141
+ totalSize += stats.size;
142
+ return {
143
+ filename,
144
+ mtime: stats.mtime,
145
+ size: stats.size,
146
+ };
147
+ } catch {
148
+ return { filename, mtime: new Date(), size: 0 };
149
+ }
55
150
  })
56
151
  );
57
152
 
58
- filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
153
+ // Get mappings
154
+ const { mappedFilenames, semanticIdToFilename } = extractMappings();
155
+
156
+ // Get database references
157
+ const { blogFilenames, userFilenames } = await queryDatabase();
158
+
159
+ // An image is "in use" if it's in mappings OR referenced in database
160
+ // First resolve database references through mappings, then check
161
+ const inUseFilenames = new Set<string>();
162
+
163
+ // Add mapped filenames
164
+ mappedFilenames.forEach(f => inUseFilenames.add(f));
165
+
166
+ // Add database references (check if they resolve through mappings)
167
+ [...blogFilenames, ...userFilenames].forEach(idOrFilename => {
168
+ const resolved = semanticIdToFilename[idOrFilename] || idOrFilename;
169
+ inUseFilenames.add(resolved);
170
+ });
171
+
172
+ const usedCount = filesWithStats.filter(f => inUseFilenames.has(f.filename)).length;
173
+
174
+ const filteredFiles = search
175
+ ? filesWithStats.filter(file => file.filename.toLowerCase().includes(search.toLowerCase()))
176
+ : filesWithStats;
177
+
178
+ filteredFiles.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
59
179
 
60
- // Paginate
61
180
  const start = (page - 1) * limit;
62
181
  const end = start + limit;
63
- const paginatedFiles = filesWithStats.slice(start, end);
182
+ const paginatedFiles = filteredFiles.slice(start, end);
64
183
 
65
- // Build image metadata
66
184
  const images = paginatedFiles.map(({ filename, mtime, size }) => {
67
185
  const ext = path.extname(filename).toLowerCase();
68
186
  const mimeType = ext === '.png' ? 'image/png' :
@@ -84,6 +202,13 @@ export async function GET(request: NextRequest) {
84
202
  total: filteredFiles.length,
85
203
  page,
86
204
  limit,
205
+ stats: {
206
+ totalImages: imageFiles.length,
207
+ totalSize,
208
+ usedImages: usedCount,
209
+ availableImages: imageFiles.length - usedCount,
210
+ },
211
+ mappedImages: Array.from(mappedFilenames),
87
212
  });
88
213
  } catch (error) {
89
214
  console.error('List images error:', error);
package/src/api/router.ts CHANGED
@@ -69,6 +69,14 @@ export async function handleImagesApi(
69
69
  }
70
70
  }
71
71
 
72
+ // Route: /api/plugin-images/usage (get image usage across plugins)
73
+ else if (route === 'usage') {
74
+ if (method === 'GET') {
75
+ const usageModule = await import('./usage/route');
76
+ return await usageModule.GET(req);
77
+ }
78
+ }
79
+
72
80
  // Method not allowed
73
81
  return NextResponse.json(
74
82
  { error: `Method ${method} not allowed for route: ${route || '/'}` },