vasuzex 2.1.19 → 2.1.21
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.
|
@@ -37,23 +37,25 @@ export class MediaManager {
|
|
|
37
37
|
const cached = await this.getCachedThumbnail(cacheKey);
|
|
38
38
|
|
|
39
39
|
if (cached) {
|
|
40
|
+
// Detect content type from cached buffer
|
|
41
|
+
const contentType = await this.detectContentType(cached);
|
|
40
42
|
return {
|
|
41
43
|
buffer: cached,
|
|
42
44
|
fromCache: true,
|
|
43
|
-
contentType
|
|
45
|
+
contentType,
|
|
44
46
|
};
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
// Generate thumbnail
|
|
48
|
-
const
|
|
50
|
+
const result = await this.generateThumbnail(imagePath, width, height);
|
|
49
51
|
|
|
50
52
|
// Cache it
|
|
51
|
-
await this.cacheThumbnail(cacheKey,
|
|
53
|
+
await this.cacheThumbnail(cacheKey, result.buffer);
|
|
52
54
|
|
|
53
55
|
return {
|
|
54
|
-
buffer:
|
|
56
|
+
buffer: result.buffer,
|
|
55
57
|
fromCache: false,
|
|
56
|
-
contentType:
|
|
58
|
+
contentType: result.contentType,
|
|
57
59
|
};
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -78,19 +80,47 @@ export class MediaManager {
|
|
|
78
80
|
const storage = this.app.make('storage');
|
|
79
81
|
const imageBuffer = await storage.get(imagePath);
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
// Detect image metadata
|
|
84
|
+
const image = sharp(imageBuffer);
|
|
85
|
+
const metadata = await image.metadata();
|
|
86
|
+
const hasAlpha = metadata.hasAlpha;
|
|
87
|
+
const originalFormat = metadata.format;
|
|
88
|
+
|
|
89
|
+
// Build the sharp pipeline
|
|
90
|
+
let pipeline = sharp(imageBuffer)
|
|
82
91
|
.resize(width, height, {
|
|
83
92
|
fit: this.config.thumbnails.fit,
|
|
84
93
|
position: this.config.thumbnails.position,
|
|
85
94
|
withoutEnlargement: true,
|
|
86
|
-
})
|
|
87
|
-
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Choose output format based on input format and transparency
|
|
98
|
+
let contentType = 'image/jpeg';
|
|
99
|
+
|
|
100
|
+
if (originalFormat === 'webp') {
|
|
101
|
+
// Preserve WebP format (supports transparency and good compression)
|
|
102
|
+
pipeline = pipeline.webp({
|
|
103
|
+
quality: this.config.thumbnails.quality,
|
|
104
|
+
});
|
|
105
|
+
contentType = 'image/webp';
|
|
106
|
+
} else if (hasAlpha) {
|
|
107
|
+
// For PNG or other formats with transparency, use PNG
|
|
108
|
+
pipeline = pipeline.png({
|
|
109
|
+
quality: this.config.thumbnails.quality,
|
|
110
|
+
compressionLevel: 9,
|
|
111
|
+
});
|
|
112
|
+
contentType = 'image/png';
|
|
113
|
+
} else {
|
|
114
|
+
// For opaque images, use JPEG for best compression
|
|
115
|
+
pipeline = pipeline.jpeg({
|
|
88
116
|
quality: this.config.thumbnails.quality,
|
|
89
117
|
progressive: true,
|
|
90
|
-
})
|
|
91
|
-
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const thumbnail = await pipeline.toBuffer();
|
|
92
122
|
|
|
93
|
-
return thumbnail;
|
|
123
|
+
return { buffer: thumbnail, contentType };
|
|
94
124
|
}
|
|
95
125
|
|
|
96
126
|
/**
|
|
@@ -139,22 +169,27 @@ export class MediaManager {
|
|
|
139
169
|
*/
|
|
140
170
|
async getCachedThumbnail(cacheKey) {
|
|
141
171
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
172
|
+
// Try all supported image extensions
|
|
173
|
+
for (const ext of ['.webp', '.png', '.jpg']) {
|
|
174
|
+
const cachePath = join(this.cacheDir, `${cacheKey}${ext}`);
|
|
175
|
+
|
|
176
|
+
if (!existsSync(cachePath)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
147
179
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
180
|
+
// Check if cache expired
|
|
181
|
+
const stats = await stat(cachePath);
|
|
182
|
+
const age = Date.now() - stats.mtimeMs;
|
|
183
|
+
|
|
184
|
+
if (age > this.cacheTTL) {
|
|
185
|
+
await unlink(cachePath);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
156
188
|
|
|
157
|
-
|
|
189
|
+
return await readFile(cachePath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return null;
|
|
158
193
|
} catch (error) {
|
|
159
194
|
return null;
|
|
160
195
|
}
|
|
@@ -165,7 +200,15 @@ export class MediaManager {
|
|
|
165
200
|
*/
|
|
166
201
|
async cacheThumbnail(cacheKey, buffer) {
|
|
167
202
|
try {
|
|
168
|
-
|
|
203
|
+
// Detect format from buffer to use correct extension
|
|
204
|
+
const metadata = await sharp(buffer).metadata();
|
|
205
|
+
const formatMap = {
|
|
206
|
+
webp: '.webp',
|
|
207
|
+
png: '.png',
|
|
208
|
+
jpeg: '.jpg',
|
|
209
|
+
};
|
|
210
|
+
const ext = formatMap[metadata.format] || '.jpg';
|
|
211
|
+
const cachePath = join(this.cacheDir, `${cacheKey}${ext}`);
|
|
169
212
|
await mkdir(dirname(cachePath), { recursive: true });
|
|
170
213
|
await writeFile(cachePath, buffer);
|
|
171
214
|
} catch (error) {
|
|
@@ -180,12 +223,12 @@ export class MediaManager {
|
|
|
180
223
|
async getCacheStats() {
|
|
181
224
|
try {
|
|
182
225
|
const files = await readdir(this.cacheDir);
|
|
183
|
-
const
|
|
226
|
+
const imageFiles = files.filter(f => f.endsWith('.jpg') || f.endsWith('.png') || f.endsWith('.webp'));
|
|
184
227
|
|
|
185
228
|
let totalSize = 0;
|
|
186
229
|
let expiredCount = 0;
|
|
187
230
|
|
|
188
|
-
for (const file of
|
|
231
|
+
for (const file of imageFiles) {
|
|
189
232
|
const filePath = join(this.cacheDir, file);
|
|
190
233
|
const stats = await stat(filePath);
|
|
191
234
|
totalSize += stats.size;
|
|
@@ -197,7 +240,7 @@ export class MediaManager {
|
|
|
197
240
|
}
|
|
198
241
|
|
|
199
242
|
return {
|
|
200
|
-
total:
|
|
243
|
+
total: imageFiles.length,
|
|
201
244
|
size: totalSize,
|
|
202
245
|
sizeFormatted: this.formatBytes(totalSize),
|
|
203
246
|
expired: expiredCount,
|
|
@@ -223,11 +266,11 @@ export class MediaManager {
|
|
|
223
266
|
async clearExpiredCache() {
|
|
224
267
|
try {
|
|
225
268
|
const files = await readdir(this.cacheDir);
|
|
226
|
-
const
|
|
269
|
+
const imageFiles = files.filter(f => f.endsWith('.jpg') || f.endsWith('.png') || f.endsWith('.webp'));
|
|
227
270
|
|
|
228
271
|
let cleared = 0;
|
|
229
272
|
|
|
230
|
-
for (const file of
|
|
273
|
+
for (const file of imageFiles) {
|
|
231
274
|
const filePath = join(this.cacheDir, file);
|
|
232
275
|
const stats = await stat(filePath);
|
|
233
276
|
const age = Date.now() - stats.mtimeMs;
|
|
@@ -251,14 +294,14 @@ export class MediaManager {
|
|
|
251
294
|
async clearAllCache() {
|
|
252
295
|
try {
|
|
253
296
|
const files = await readdir(this.cacheDir);
|
|
254
|
-
const
|
|
297
|
+
const imageFiles = files.filter(f => f.endsWith('.jpg') || f.endsWith('.png') || f.endsWith('.webp'));
|
|
255
298
|
|
|
256
|
-
for (const file of
|
|
299
|
+
for (const file of imageFiles) {
|
|
257
300
|
const filePath = join(this.cacheDir, file);
|
|
258
301
|
await unlink(filePath);
|
|
259
302
|
}
|
|
260
303
|
|
|
261
|
-
return
|
|
304
|
+
return imageFiles.length;
|
|
262
305
|
} catch (error) {
|
|
263
306
|
console.error('Failed to clear cache:', error.message);
|
|
264
307
|
return 0;
|
|
@@ -293,6 +336,25 @@ export class MediaManager {
|
|
|
293
336
|
return types[ext] || 'application/octet-stream';
|
|
294
337
|
}
|
|
295
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Detect content type from buffer
|
|
341
|
+
*/
|
|
342
|
+
async detectContentType(buffer) {
|
|
343
|
+
try {
|
|
344
|
+
const metadata = await sharp(buffer).metadata();
|
|
345
|
+
const formatMap = {
|
|
346
|
+
jpeg: 'image/jpeg',
|
|
347
|
+
png: 'image/png',
|
|
348
|
+
gif: 'image/gif',
|
|
349
|
+
webp: 'image/webp',
|
|
350
|
+
svg: 'image/svg+xml',
|
|
351
|
+
};
|
|
352
|
+
return formatMap[metadata.format] || 'image/jpeg';
|
|
353
|
+
} catch (error) {
|
|
354
|
+
return 'image/jpeg';
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
296
358
|
/**
|
|
297
359
|
* Format bytes to human readable
|
|
298
360
|
*/
|
|
@@ -42,7 +42,10 @@ export function RowActionsCell({
|
|
|
42
42
|
}) {
|
|
43
43
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
44
44
|
const menuRef = useRef(null);
|
|
45
|
-
|
|
45
|
+
|
|
46
|
+
// Determine which approval actions to show based on current status
|
|
47
|
+
const showApprove = hasApproval && (row.approval_status === 'pending' || row.approval_status === 'rejected');
|
|
48
|
+
const showReject = hasApproval && (row.approval_status === 'pending' || row.approval_status === 'approved');
|
|
46
49
|
|
|
47
50
|
// Close menu when clicking outside
|
|
48
51
|
useEffect(() => {
|
|
@@ -106,10 +109,10 @@ export function RowActionsCell({
|
|
|
106
109
|
|
|
107
110
|
{isMenuOpen && (
|
|
108
111
|
<div className="absolute right-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50 py-1">
|
|
109
|
-
{/* Approval Actions -
|
|
110
|
-
{
|
|
112
|
+
{/* Approval Actions - Show based on current status */}
|
|
113
|
+
{(showApprove || showReject) && (
|
|
111
114
|
<>
|
|
112
|
-
{onApprove && (
|
|
115
|
+
{showApprove && onApprove && (
|
|
113
116
|
<button
|
|
114
117
|
onClick={() => {
|
|
115
118
|
onApprove(row);
|
|
@@ -118,10 +121,10 @@ export function RowActionsCell({
|
|
|
118
121
|
className="w-full flex items-center gap-3 px-4 py-2 text-sm text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-950/30"
|
|
119
122
|
>
|
|
120
123
|
<CheckCircle className="h-4 w-4" />
|
|
121
|
-
<span>Approve</span>
|
|
124
|
+
<span>{row.approval_status === 'rejected' ? 'Re-approve' : 'Approve'}</span>
|
|
122
125
|
</button>
|
|
123
126
|
)}
|
|
124
|
-
{onReject && (
|
|
127
|
+
{showReject && onReject && (
|
|
125
128
|
<button
|
|
126
129
|
onClick={() => {
|
|
127
130
|
onReject(row);
|
|
@@ -130,7 +133,7 @@ export function RowActionsCell({
|
|
|
130
133
|
className="w-full flex items-center gap-3 px-4 py-2 text-sm text-rose-700 hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-950/30"
|
|
131
134
|
>
|
|
132
135
|
<XCircle className="h-4 w-4" />
|
|
133
|
-
<span>Reject</span>
|
|
136
|
+
<span>{row.approval_status === 'approved' ? 'Revoke/Reject' : 'Reject'}</span>
|
|
134
137
|
</button>
|
|
135
138
|
)}
|
|
136
139
|
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|