@firecms/media_manager 3.1.0-canary.1df3b2c

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.
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ import React, { useMemo, createContext, useContext, PropsWithChildren } from "react";
2
+ import { FireCMSPlugin, CMSView } from "@firecms/core";
3
+ import { MediaManagerConfig } from "./types";
4
+ import { MediaManagerProvider } from "./MediaManagerProvider";
5
+ import { useMediaManagerController } from "./useMediaManagerController";
6
+ import { MediaLibraryCard } from "./components/MediaLibraryCard";
7
+ import { MediaLibraryView } from "./components/MediaLibraryView";
8
+
9
+ const DEFAULT_STORAGE_PATH = "media";
10
+ const DEFAULT_COLLECTION_PATH = "media_assets";
11
+
12
+ export interface MediaManagerPluginProps extends MediaManagerConfig { }
13
+
14
+ // Context to store the config
15
+ const MediaManagerConfigContext = createContext<MediaManagerConfig | null>(null);
16
+
17
+ function useMediaManagerConfig(): MediaManagerConfig {
18
+ const config = useContext(MediaManagerConfigContext);
19
+ if (!config) {
20
+ throw new Error("useMediaManagerConfig must be used within MediaManagerConfigProvider");
21
+ }
22
+ return config;
23
+ }
24
+
25
+ /**
26
+ * Internal wrapper that reads config from context
27
+ */
28
+ function MediaLibraryViewInternal() {
29
+ const config = useMediaManagerConfig();
30
+ const controller = useMediaManagerController({
31
+ storageSource: config.storageSource,
32
+ dataSourceDelegate: config.dataSourceDelegate,
33
+ storagePath: config.storagePath ?? DEFAULT_STORAGE_PATH,
34
+ collectionPath: config.collectionPath ?? DEFAULT_COLLECTION_PATH,
35
+ bucket: config.bucket,
36
+ thumbnailSizes: config.thumbnailSizes,
37
+ thumbnailPath: config.thumbnailPath
38
+ });
39
+
40
+ return (
41
+ <MediaManagerProvider controller={controller}>
42
+ <MediaLibraryView
43
+ maxFileSize={config.maxFileSize}
44
+ acceptedMimeTypes={config.acceptedMimeTypes}
45
+ />
46
+ </MediaManagerProvider>
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Build the media view - this is a static object that doesn't change
52
+ */
53
+ function buildMediaView(): CMSView {
54
+ return {
55
+ path: "media",
56
+ name: "Media Library",
57
+ description: "Manage your media files and assets",
58
+ group: "Media",
59
+ icon: "perm_media",
60
+ view: <MediaLibraryViewInternal />
61
+ };
62
+ }
63
+
64
+ // Single static instance of the view
65
+ const MEDIA_VIEW = buildMediaView();
66
+
67
+ /**
68
+ * Hook to create the Media Manager plugin for FireCMS.
69
+ *
70
+ * The plugin automatically registers the Media Library view in the navigation.
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * const { plugin: mediaManagerPlugin } = useMediaManagerPlugin({
75
+ * storageSource,
76
+ * dataSourceDelegate: firestoreDelegate,
77
+ * storagePath: "media",
78
+ * collectionPath: "media_assets"
79
+ * });
80
+ *
81
+ * // Add plugin to your plugins array - view is auto-registered
82
+ * const plugins = [mediaManagerPlugin, ...otherPlugins];
83
+ * ```
84
+ */
85
+ export function useMediaManagerPlugin(props: MediaManagerPluginProps): FireCMSPlugin {
86
+ return useMemo(() => ({
87
+ key: "media_manager",
88
+ views: [MEDIA_VIEW],
89
+ provider: {
90
+ Component: ({ children }: PropsWithChildren) => (
91
+ <MediaManagerConfigContext.Provider value={props}>
92
+ {children}
93
+ </MediaManagerConfigContext.Provider>
94
+ )
95
+ }
96
+ } satisfies FireCMSPlugin), []);
97
+ }