@firecms/media_manager 3.1.0-canary.02232f4

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.
Files changed (48) hide show
  1. package/LICENSE +6 -0
  2. package/dist/MediaManagerProvider.d.ts +14 -0
  3. package/dist/components/MediaAssetCard.d.ts +13 -0
  4. package/dist/components/MediaAssetDetails.d.ts +11 -0
  5. package/dist/components/MediaLibraryCard.d.ts +8 -0
  6. package/dist/components/MediaLibraryView.d.ts +9 -0
  7. package/dist/components/MediaUploadDialog.d.ts +13 -0
  8. package/dist/components/index.d.ts +5 -0
  9. package/dist/index.d.ts +5 -0
  10. package/dist/index.es.js +1741 -0
  11. package/dist/index.es.js.map +1 -0
  12. package/dist/index.umd.js +1737 -0
  13. package/dist/index.umd.js.map +1 -0
  14. package/dist/locales/de.d.ts +45 -0
  15. package/dist/locales/en.d.ts +45 -0
  16. package/dist/locales/es.d.ts +45 -0
  17. package/dist/locales/fr.d.ts +45 -0
  18. package/dist/locales/hi.d.ts +45 -0
  19. package/dist/locales/it.d.ts +45 -0
  20. package/dist/locales/pt.d.ts +45 -0
  21. package/dist/types/config.d.ts +87 -0
  22. package/dist/types/controller.d.ts +66 -0
  23. package/dist/types/index.d.ts +3 -0
  24. package/dist/types/media_asset.d.ts +70 -0
  25. package/dist/useMediaManagerController.d.ts +18 -0
  26. package/dist/useMediaManagerPlugin.d.ts +23 -0
  27. package/package.json +58 -0
  28. package/src/MediaManagerProvider.tsx +34 -0
  29. package/src/components/MediaAssetCard.tsx +134 -0
  30. package/src/components/MediaAssetDetails.tsx +353 -0
  31. package/src/components/MediaLibraryCard.tsx +58 -0
  32. package/src/components/MediaLibraryView.tsx +196 -0
  33. package/src/components/MediaUploadDialog.tsx +266 -0
  34. package/src/components/index.ts +5 -0
  35. package/src/index.ts +10 -0
  36. package/src/locales/de.ts +45 -0
  37. package/src/locales/en.ts +45 -0
  38. package/src/locales/es.ts +45 -0
  39. package/src/locales/fr.ts +45 -0
  40. package/src/locales/hi.ts +45 -0
  41. package/src/locales/it.ts +45 -0
  42. package/src/locales/pt.ts +45 -0
  43. package/src/types/config.ts +100 -0
  44. package/src/types/controller.ts +79 -0
  45. package/src/types/index.ts +3 -0
  46. package/src/types/media_asset.ts +84 -0
  47. package/src/useMediaManagerController.tsx +387 -0
  48. package/src/useMediaManagerPlugin.tsx +114 -0
@@ -0,0 +1,100 @@
1
+ import { DataSourceDelegate, StorageSource } from "@firecms/core";
2
+
3
+ /**
4
+ * Configuration for a thumbnail size.
5
+ * Thumbnails are generated client-side before upload.
6
+ */
7
+ export interface ThumbnailSize {
8
+ /**
9
+ * Unique name for this size (e.g., "small", "medium", "large").
10
+ * Used as a key in the thumbnails record.
11
+ */
12
+ name: string;
13
+
14
+ /**
15
+ * Maximum width in pixels.
16
+ * The image will be scaled to fit within this width while maintaining aspect ratio.
17
+ */
18
+ width: number;
19
+
20
+ /**
21
+ * Maximum height in pixels.
22
+ * The image will be scaled to fit within this height while maintaining aspect ratio.
23
+ */
24
+ height: number;
25
+
26
+ /**
27
+ * JPEG quality for the thumbnail (0-1).
28
+ * @default 0.8
29
+ */
30
+ quality?: number;
31
+ }
32
+
33
+ /**
34
+ * Configuration options for the Media Manager plugin.
35
+ */
36
+ export interface MediaManagerConfig {
37
+ /**
38
+ * Storage source for file operations (upload, download, delete).
39
+ * Typically useFirebaseStorageSource() or similar.
40
+ */
41
+ storageSource: StorageSource;
42
+
43
+ /**
44
+ * Data source delegate for metadata persistence.
45
+ * Typically useFirestoreDelegate() or similar.
46
+ */
47
+ dataSourceDelegate: DataSourceDelegate;
48
+
49
+ /**
50
+ * Path in storage where media files will be uploaded.
51
+ * @default "media"
52
+ */
53
+ storagePath?: string;
54
+
55
+ /**
56
+ * Collection path in the database for storing asset metadata.
57
+ * @default "media_assets"
58
+ */
59
+ collectionPath?: string;
60
+
61
+ /**
62
+ * Optional bucket name for storage operations.
63
+ * If not specified, uses the default bucket.
64
+ */
65
+ bucket?: string;
66
+
67
+ /**
68
+ * Maximum file size allowed for uploads (in bytes).
69
+ * @default 52428800 (50MB)
70
+ */
71
+ maxFileSize?: number;
72
+
73
+ /**
74
+ * Allowed MIME types for uploads.
75
+ * If not specified, all file types are allowed.
76
+ */
77
+ acceptedMimeTypes?: string[];
78
+
79
+ /**
80
+ * Thumbnail sizes to generate on upload.
81
+ * Thumbnails are generated client-side using canvas before upload.
82
+ * If not specified, no thumbnails are generated.
83
+ *
84
+ * @example
85
+ * ```typescript
86
+ * thumbnailSizes: [
87
+ * { name: "small", width: 150, height: 150 },
88
+ * { name: "medium", width: 400, height: 400 }
89
+ * ]
90
+ * ```
91
+ */
92
+ thumbnailSizes?: ThumbnailSize[];
93
+
94
+ /**
95
+ * Subfolder path for storing thumbnails.
96
+ * Thumbnails are stored under `{storagePath}/{thumbnailPath}/{sizeName}/`.
97
+ * @default "thumbs"
98
+ */
99
+ thumbnailPath?: string;
100
+ }
@@ -0,0 +1,79 @@
1
+ import { MediaAsset } from "./media_asset";
2
+
3
+ /**
4
+ * Controller interface for managing media assets.
5
+ * Provides methods for CRUD operations and state management.
6
+ */
7
+ export interface MediaManagerController {
8
+ /**
9
+ * Whether the controller is currently loading data
10
+ */
11
+ loading: boolean;
12
+
13
+ /**
14
+ * Error if any operation failed
15
+ */
16
+ error?: Error;
17
+
18
+ /**
19
+ * List of loaded media assets
20
+ */
21
+ assets: MediaAsset[];
22
+
23
+ /**
24
+ * Total count of assets (may differ from assets.length during pagination)
25
+ */
26
+ totalCount?: number;
27
+
28
+ /**
29
+ * Currently selected asset (for details view)
30
+ */
31
+ selectedAsset?: MediaAsset;
32
+
33
+ /**
34
+ * Select an asset for viewing/editing
35
+ */
36
+ selectAsset: (asset: MediaAsset | undefined) => void;
37
+
38
+ /**
39
+ * Upload a new file to the media library
40
+ * @param file The file to upload
41
+ * @param metadata Optional metadata to attach
42
+ * @returns The created MediaAsset
43
+ */
44
+ uploadFile: (file: File, metadata?: Partial<Pick<MediaAsset, "title" | "altText" | "caption" | "tags">>) => Promise<MediaAsset>;
45
+
46
+ /**
47
+ * Delete an asset from storage and database
48
+ * @param assetId The ID of the asset to delete
49
+ */
50
+ deleteAsset: (assetId: string) => Promise<void>;
51
+
52
+ /**
53
+ * Update asset metadata
54
+ * @param assetId The ID of the asset to update
55
+ * @param data The fields to update
56
+ */
57
+ updateAsset: (assetId: string, data: Partial<MediaAsset>) => Promise<void>;
58
+
59
+ /**
60
+ * Refresh the assets list from the database
61
+ */
62
+ refreshAssets: () => Promise<void>;
63
+
64
+ /**
65
+ * Search assets by text
66
+ * @param query Search query string
67
+ */
68
+ searchAssets: (query: string) => void;
69
+
70
+ /**
71
+ * Storage path configuration
72
+ */
73
+ storagePath: string;
74
+
75
+ /**
76
+ * Collection path configuration
77
+ */
78
+ collectionPath: string;
79
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./media_asset";
2
+ export * from "./config";
3
+ export * from "./controller";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Represents a media asset stored in the media library.
3
+ * This interface defines the metadata stored in the database for each file.
4
+ */
5
+ export interface MediaAsset {
6
+ /**
7
+ * Unique identifier for the asset (document ID in the database)
8
+ */
9
+ id: string;
10
+
11
+ /**
12
+ * Original file name
13
+ */
14
+ fileName: string;
15
+
16
+ /**
17
+ * Path in storage where the file is located
18
+ */
19
+ storagePath: string;
20
+
21
+ /**
22
+ * Cached download URL for quick access
23
+ */
24
+ downloadURL?: string;
25
+
26
+ /**
27
+ * MIME type of the file (e.g., "image/jpeg", "application/pdf")
28
+ */
29
+ mimeType: string;
30
+
31
+ /**
32
+ * File size in bytes
33
+ */
34
+ size: number;
35
+
36
+ /**
37
+ * Dimensions for image/video files
38
+ */
39
+ dimensions?: {
40
+ width: number;
41
+ height: number;
42
+ };
43
+
44
+ /**
45
+ * User-defined title for the asset
46
+ */
47
+ title?: string;
48
+
49
+ /**
50
+ * Alternative text for accessibility (SEO recommended)
51
+ */
52
+ altText?: string;
53
+
54
+ /**
55
+ * Optional caption/description
56
+ */
57
+ caption?: string;
58
+
59
+ /**
60
+ * User-defined tags for categorization
61
+ */
62
+ tags?: string[];
63
+
64
+ /**
65
+ * Timestamp when the asset was created
66
+ */
67
+ createdAt: Date;
68
+
69
+ /**
70
+ * Timestamp when the asset was last updated
71
+ */
72
+ updatedAt: Date;
73
+
74
+ /**
75
+ * Storage bucket name (for multi-bucket setups)
76
+ */
77
+ bucket?: string;
78
+
79
+ /**
80
+ * Thumbnail URLs keyed by size name (e.g., "small", "medium").
81
+ * These are generated during upload based on thumbnailSizes config.
82
+ */
83
+ thumbnails?: Record<string, string>;
84
+ }
@@ -0,0 +1,387 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { DataSourceDelegate, StorageSource } from "@firecms/core";
3
+ import { MediaAsset, MediaManagerController, ThumbnailSize } from "./types";
4
+ import Compressor from "compressorjs";
5
+
6
+ export interface UseMediaManagerControllerProps {
7
+ storageSource: StorageSource;
8
+ dataSourceDelegate: DataSourceDelegate;
9
+ storagePath: string;
10
+ collectionPath: string;
11
+ bucket?: string;
12
+ /** Thumbnail sizes to generate on upload */
13
+ thumbnailSizes?: ThumbnailSize[];
14
+ /** Path prefix for thumbnails. Default: "thumbs" */
15
+ thumbnailPath?: string;
16
+ }
17
+
18
+ const DEFAULT_THUMBNAIL_PATH = "thumbs";
19
+
20
+ /**
21
+ * Hook that creates a MediaManagerController for managing media assets.
22
+ * Handles all CRUD operations for files and their metadata.
23
+ */
24
+ export function useMediaManagerController({
25
+ storageSource,
26
+ dataSourceDelegate,
27
+ storagePath,
28
+ collectionPath,
29
+ bucket,
30
+ thumbnailSizes,
31
+ thumbnailPath = DEFAULT_THUMBNAIL_PATH
32
+ }: UseMediaManagerControllerProps): MediaManagerController {
33
+ const [loading, setLoading] = useState(true);
34
+ const [error, setError] = useState<Error | undefined>();
35
+ const [assets, setAssets] = useState<MediaAsset[]>([]);
36
+ const [selectedAsset, setSelectedAsset] = useState<MediaAsset | undefined>();
37
+ const [searchQuery, setSearchQuery] = useState("");
38
+
39
+ // Helper to fetch download URL for an asset
40
+ const fetchDownloadURL = useCallback(async (asset: Omit<MediaAsset, "downloadURL">): Promise<MediaAsset> => {
41
+ try {
42
+ const downloadConfig = await storageSource.getDownloadURL(asset.storagePath, asset.bucket);
43
+ return {
44
+ ...asset,
45
+ downloadURL: downloadConfig.url ?? undefined
46
+ };
47
+ } catch (err) {
48
+ console.warn(`Failed to get download URL for ${asset.storagePath}:`, err);
49
+ return { ...asset, downloadURL: undefined };
50
+ }
51
+ }, [storageSource]);
52
+
53
+ // Fetch assets from the database
54
+ const refreshAssets = useCallback(async () => {
55
+ setLoading(true);
56
+ setError(undefined);
57
+ try {
58
+ console.log("Fetching media assets from:", collectionPath);
59
+ const entities = await dataSourceDelegate.fetchCollection<Record<string, any>>({
60
+ path: collectionPath,
61
+ orderBy: "createdAt",
62
+ order: "desc"
63
+ });
64
+
65
+ console.log("Fetched entities:", entities.length);
66
+
67
+ if (entities.length === 0) {
68
+ setAssets([]);
69
+ return;
70
+ }
71
+
72
+ // Convert entities to assets and fetch download URLs
73
+ const loadedAssets: MediaAsset[] = await Promise.all(
74
+ entities.map(async (entity) => {
75
+ const values = entity.values;
76
+ const baseAsset: Omit<MediaAsset, "downloadURL"> = {
77
+ id: entity.id,
78
+ fileName: values.fileName,
79
+ storagePath: values.storagePath,
80
+ mimeType: values.mimeType,
81
+ size: values.size,
82
+ dimensions: values.dimensions,
83
+ title: values.title,
84
+ altText: values.altText,
85
+ caption: values.caption,
86
+ tags: values.tags,
87
+ bucket: values.bucket,
88
+ createdAt: values.createdAt instanceof Date
89
+ ? values.createdAt
90
+ : (values.createdAt?.toDate ? values.createdAt.toDate() : new Date(values.createdAt)),
91
+ updatedAt: values.updatedAt instanceof Date
92
+ ? values.updatedAt
93
+ : (values.updatedAt?.toDate ? values.updatedAt.toDate() : new Date(values.updatedAt))
94
+ };
95
+
96
+ // Fetch download URL for images
97
+ if (baseAsset.mimeType?.startsWith("image/") || baseAsset.mimeType?.startsWith("video/")) {
98
+ return await fetchDownloadURL(baseAsset);
99
+ }
100
+ return { ...baseAsset, downloadURL: undefined };
101
+ })
102
+ );
103
+
104
+ console.log("Loaded assets with URLs:", loadedAssets.length);
105
+ setAssets(loadedAssets);
106
+ } catch (err) {
107
+ console.error("Error fetching media assets:", err);
108
+ setError(err instanceof Error ? err : new Error(String(err)));
109
+ } finally {
110
+ setLoading(false);
111
+ }
112
+ }, [dataSourceDelegate, collectionPath, fetchDownloadURL]);
113
+
114
+ // Initial load
115
+ useEffect(() => {
116
+ refreshAssets();
117
+ }, [refreshAssets]);
118
+
119
+ // Upload a new file
120
+ const uploadFile = useCallback(async (
121
+ file: File,
122
+ metadata?: Partial<Pick<MediaAsset, "title" | "altText" | "caption" | "tags">>
123
+ ): Promise<MediaAsset> => {
124
+ console.log("Uploading file:", file.name, "to path:", storagePath);
125
+
126
+ // Upload to storage
127
+ const uploadResult = await storageSource.uploadFile({
128
+ file,
129
+ fileName: file.name,
130
+ path: storagePath,
131
+ bucket
132
+ });
133
+
134
+ console.log("Upload result:", uploadResult);
135
+
136
+ // Get download URL immediately after upload
137
+ let downloadURL: string | undefined;
138
+ try {
139
+ const downloadConfig = await storageSource.getDownloadURL(uploadResult.path, uploadResult.bucket);
140
+ downloadURL = downloadConfig.url ?? undefined;
141
+ console.log("Got download URL:", downloadURL);
142
+ } catch (err) {
143
+ console.warn("Failed to get download URL after upload:", err);
144
+ }
145
+
146
+ // Get dimensions for images
147
+ let dimensions: { width: number; height: number } | undefined;
148
+ if (file.type.startsWith("image/")) {
149
+ try {
150
+ dimensions = await getImageDimensions(file);
151
+ } catch (err) {
152
+ console.warn("Failed to get image dimensions:", err);
153
+ }
154
+ }
155
+
156
+ // Generate thumbnails if configured and file is a raster image (skip SVGs)
157
+ const thumbnails: Record<string, string> = {};
158
+ const isRasterImage = file.type.startsWith("image/") && file.type !== "image/svg+xml";
159
+ if (thumbnailSizes && thumbnailSizes.length > 0 && isRasterImage) {
160
+ console.log("Generating thumbnails for sizes:", thumbnailSizes.map(s => s.name));
161
+
162
+ for (const size of thumbnailSizes) {
163
+ try {
164
+ const thumbnailBlob = await generateThumbnail(file, size.width, size.height, size.quality ?? 0.8);
165
+ const thumbFileName = `${Date.now()}_${size.name}_${file.name}`;
166
+ const thumbPath = `${storagePath}/${thumbnailPath}/${size.name}`;
167
+
168
+ // Upload thumbnail
169
+ const thumbUploadResult = await storageSource.uploadFile({
170
+ file: new File([thumbnailBlob], thumbFileName, { type: "image/jpeg" }),
171
+ fileName: thumbFileName,
172
+ path: thumbPath,
173
+ bucket
174
+ });
175
+
176
+ // Get download URL for thumbnail
177
+ const thumbDownloadConfig = await storageSource.getDownloadURL(thumbUploadResult.path, thumbUploadResult.bucket);
178
+ if (thumbDownloadConfig.url) {
179
+ thumbnails[size.name] = thumbDownloadConfig.url;
180
+ console.log(`Generated ${size.name} thumbnail:`, thumbDownloadConfig.url);
181
+ }
182
+ } catch (err) {
183
+ console.warn(`Failed to generate ${size.name} thumbnail:`, err);
184
+ }
185
+ }
186
+ }
187
+
188
+ const now = new Date();
189
+
190
+ // Build asset data, only including defined values (Firestore doesn't allow undefined)
191
+ const assetData: Record<string, any> = {
192
+ fileName: file.name,
193
+ storagePath: uploadResult.path,
194
+ mimeType: file.type,
195
+ size: file.size,
196
+ createdAt: now,
197
+ updatedAt: now
198
+ };
199
+
200
+ // Only add optional fields if they are defined
201
+ if (dimensions) assetData.dimensions = dimensions;
202
+ if (metadata?.title) assetData.title = metadata.title;
203
+ if (metadata?.altText) assetData.altText = metadata.altText;
204
+ if (metadata?.caption) assetData.caption = metadata.caption;
205
+ if (metadata?.tags && metadata.tags.length > 0) assetData.tags = metadata.tags;
206
+ if (uploadResult.bucket) assetData.bucket = uploadResult.bucket;
207
+ // Store downloadURL in database for quick access later
208
+ if (downloadURL) assetData.downloadURL = downloadURL;
209
+ // Store thumbnails if any were generated
210
+ if (Object.keys(thumbnails).length > 0) assetData.thumbnails = thumbnails;
211
+
212
+ console.log("Saving asset data to database:", assetData);
213
+
214
+ // Save metadata to database
215
+ const entity = await dataSourceDelegate.saveEntity<Record<string, any>>({
216
+ path: collectionPath,
217
+ values: assetData,
218
+ status: "new"
219
+ });
220
+
221
+ console.log("Saved entity:", entity.id);
222
+
223
+ const newAsset: MediaAsset = {
224
+ id: entity.id,
225
+ fileName: file.name,
226
+ storagePath: uploadResult.path,
227
+ downloadURL,
228
+ mimeType: file.type,
229
+ size: file.size,
230
+ createdAt: now,
231
+ updatedAt: now,
232
+ dimensions,
233
+ title: metadata?.title,
234
+ altText: metadata?.altText,
235
+ caption: metadata?.caption,
236
+ tags: metadata?.tags,
237
+ bucket: uploadResult.bucket,
238
+ thumbnails: Object.keys(thumbnails).length > 0 ? thumbnails : undefined
239
+ };
240
+
241
+ setAssets(prev => [newAsset, ...prev]);
242
+ return newAsset;
243
+ }, [storageSource, dataSourceDelegate, storagePath, collectionPath, bucket, thumbnailSizes, thumbnailPath]);
244
+
245
+ // Delete an asset
246
+ const deleteAsset = useCallback(async (assetId: string): Promise<void> => {
247
+ const asset = assets.find(a => a.id === assetId);
248
+ if (!asset) {
249
+ throw new Error(`Asset with id ${assetId} not found`);
250
+ }
251
+
252
+ console.log("Deleting asset:", assetId, asset.storagePath);
253
+
254
+ // Delete from storage
255
+ try {
256
+ await storageSource.deleteFile(asset.storagePath, asset.bucket);
257
+ } catch (err) {
258
+ console.warn("Failed to delete from storage (may not exist):", err);
259
+ }
260
+
261
+ // Delete from database
262
+ await dataSourceDelegate.deleteEntity({
263
+ entity: {
264
+ id: assetId,
265
+ path: collectionPath,
266
+ values: asset
267
+ }
268
+ });
269
+
270
+ setAssets(prev => prev.filter(a => a.id !== assetId));
271
+ if (selectedAsset?.id === assetId) {
272
+ setSelectedAsset(undefined);
273
+ }
274
+ }, [assets, storageSource, dataSourceDelegate, collectionPath, selectedAsset]);
275
+
276
+ // Update asset metadata
277
+ const updateAsset = useCallback(async (
278
+ assetId: string,
279
+ data: Partial<MediaAsset>
280
+ ): Promise<void> => {
281
+ const asset = assets.find(a => a.id === assetId);
282
+ if (!asset) {
283
+ throw new Error(`Asset with id ${assetId} not found`);
284
+ }
285
+
286
+ // Filter out undefined values
287
+ const cleanData: Record<string, any> = {};
288
+ Object.entries(data).forEach(([key, value]) => {
289
+ if (value !== undefined) {
290
+ cleanData[key] = value;
291
+ }
292
+ });
293
+ cleanData.updatedAt = new Date();
294
+
295
+ console.log("Updating asset:", assetId, cleanData);
296
+
297
+ await dataSourceDelegate.saveEntity({
298
+ path: collectionPath,
299
+ entityId: assetId,
300
+ values: cleanData,
301
+ previousValues: asset,
302
+ status: "existing"
303
+ });
304
+
305
+ setAssets(prev => prev.map(a =>
306
+ a.id === assetId ? { ...a, ...cleanData } : a
307
+ ));
308
+
309
+ if (selectedAsset?.id === assetId) {
310
+ setSelectedAsset(prev => prev ? { ...prev, ...cleanData } : prev);
311
+ }
312
+ }, [assets, dataSourceDelegate, collectionPath, selectedAsset]);
313
+
314
+ // Search assets (client-side filtering for now)
315
+ const searchAssets = useCallback((query: string) => {
316
+ setSearchQuery(query);
317
+ }, []);
318
+
319
+ const selectAsset = useCallback((asset: MediaAsset | undefined) => {
320
+ setSelectedAsset(asset);
321
+ }, []);
322
+
323
+ // Filter assets based on search query
324
+ const filteredAssets = searchQuery
325
+ ? assets.filter(asset =>
326
+ asset.fileName.toLowerCase().includes(searchQuery.toLowerCase()) ||
327
+ asset.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
328
+ asset.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
329
+ )
330
+ : assets;
331
+
332
+ return {
333
+ loading,
334
+ error,
335
+ assets: filteredAssets,
336
+ totalCount: assets.length,
337
+ selectedAsset,
338
+ selectAsset,
339
+ uploadFile,
340
+ deleteAsset,
341
+ updateAsset,
342
+ refreshAssets,
343
+ searchAssets,
344
+ storagePath,
345
+ collectionPath
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Helper to get image dimensions from a File
351
+ */
352
+ function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
353
+ return new Promise((resolve, reject) => {
354
+ const img = new Image();
355
+ img.onload = () => {
356
+ resolve({ width: img.width, height: img.height });
357
+ URL.revokeObjectURL(img.src);
358
+ };
359
+ img.onerror = reject;
360
+ img.src = URL.createObjectURL(file);
361
+ });
362
+ }
363
+
364
+ /**
365
+ * Generate a thumbnail from an image file using compressorjs.
366
+ * Uses the same library as the core FireCMS image resize implementation.
367
+ * Maintains aspect ratio while fitting within maxWidth x maxHeight.
368
+ */
369
+ async function generateThumbnail(
370
+ file: File,
371
+ maxWidth: number,
372
+ maxHeight: number,
373
+ quality: number
374
+ ): Promise<Blob> {
375
+ return new Promise<Blob>((resolve, reject) => {
376
+ new Compressor(file, {
377
+ quality,
378
+ maxWidth,
379
+ maxHeight,
380
+ mimeType: "image/jpeg",
381
+ success: (result) => {
382
+ resolve(result);
383
+ },
384
+ error: reject,
385
+ });
386
+ });
387
+ }