@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.
- package/LICENSE +6 -0
- package/dist/MediaManagerProvider.d.ts +14 -0
- package/dist/components/MediaAssetCard.d.ts +13 -0
- package/dist/components/MediaAssetDetails.d.ts +11 -0
- package/dist/components/MediaLibraryCard.d.ts +8 -0
- package/dist/components/MediaLibraryView.d.ts +9 -0
- package/dist/components/MediaUploadDialog.d.ts +13 -0
- package/dist/components/index.d.ts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.es.js +1741 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +1737 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/locales/de.d.ts +45 -0
- package/dist/locales/en.d.ts +45 -0
- package/dist/locales/es.d.ts +45 -0
- package/dist/locales/fr.d.ts +45 -0
- package/dist/locales/hi.d.ts +45 -0
- package/dist/locales/it.d.ts +45 -0
- package/dist/locales/pt.d.ts +45 -0
- package/dist/types/config.d.ts +87 -0
- package/dist/types/controller.d.ts +66 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/media_asset.d.ts +70 -0
- package/dist/useMediaManagerController.d.ts +18 -0
- package/dist/useMediaManagerPlugin.d.ts +23 -0
- package/package.json +58 -0
- package/src/MediaManagerProvider.tsx +34 -0
- package/src/components/MediaAssetCard.tsx +134 -0
- package/src/components/MediaAssetDetails.tsx +353 -0
- package/src/components/MediaLibraryCard.tsx +58 -0
- package/src/components/MediaLibraryView.tsx +196 -0
- package/src/components/MediaUploadDialog.tsx +266 -0
- package/src/components/index.ts +5 -0
- package/src/index.ts +10 -0
- package/src/locales/de.ts +45 -0
- package/src/locales/en.ts +45 -0
- package/src/locales/es.ts +45 -0
- package/src/locales/fr.ts +45 -0
- package/src/locales/hi.ts +45 -0
- package/src/locales/it.ts +45 -0
- package/src/locales/pt.ts +45 -0
- package/src/types/config.ts +100 -0
- package/src/types/controller.ts +79 -0
- package/src/types/index.ts +3 -0
- package/src/types/media_asset.ts +84 -0
- package/src/useMediaManagerController.tsx +387 -0
- 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,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
|
+
}
|