@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,194 @@
1
+ import React, { useCallback, useRef, useState } from "react";
2
+ import {
3
+ Button,
4
+ Container,
5
+ SearchBar,
6
+ Typography,
7
+ cls,
8
+ AddIcon,
9
+ CircularProgress,
10
+ RefreshIcon,
11
+ IconButton,
12
+ Tooltip,
13
+ AppsIcon,
14
+ Icon
15
+ } from "@firecms/ui";
16
+ import { useMediaManager } from "../MediaManagerProvider";
17
+ import { MediaAssetCard } from "./MediaAssetCard";
18
+ import { MediaAssetDetails } from "./MediaAssetDetails";
19
+ import { MediaUploadDialog } from "./MediaUploadDialog";
20
+
21
+ export interface MediaLibraryViewProps {
22
+ maxFileSize?: number;
23
+ acceptedMimeTypes?: string[];
24
+ }
25
+
26
+ /**
27
+ * Main view component for the Media Library.
28
+ * Displays a grid of assets with search, upload, and management capabilities.
29
+ */
30
+ export function MediaLibraryView({
31
+ maxFileSize,
32
+ acceptedMimeTypes
33
+ }: MediaLibraryViewProps) {
34
+ const controller = useMediaManager();
35
+ const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
36
+ const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
37
+ const fileInputRef = useRef<HTMLInputElement>(null);
38
+
39
+ const handleSearch = useCallback((query?: string) => {
40
+ controller.searchAssets(query ?? "");
41
+ }, [controller]);
42
+
43
+ const handleUploadClick = useCallback(() => {
44
+ setUploadDialogOpen(true);
45
+ }, []);
46
+
47
+ const handleFileSelect = useCallback(async (files: File[]) => {
48
+ for (const file of files) {
49
+ await controller.uploadFile(file);
50
+ }
51
+ setUploadDialogOpen(false);
52
+ }, [controller]);
53
+
54
+ const handleRefresh = useCallback(() => {
55
+ controller.refreshAssets();
56
+ }, [controller]);
57
+
58
+ return (
59
+ <div className="h-full flex flex-col overflow-hidden">
60
+ {/* Header */}
61
+ <div
62
+ className="flex-shrink-0 border-b border-surface-accent-200 dark:border-surface-accent-700 bg-surface-50 dark:bg-surface-900">
63
+ <Container maxWidth="6xl" className="py-4">
64
+ <div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
65
+ <div className="flex items-center gap-3">
66
+ <Typography variant="h5" className="font-semibold">
67
+ Media Library
68
+ </Typography>
69
+ {controller.totalCount !== undefined && (
70
+ <Typography
71
+ variant="caption"
72
+ className="bg-surface-accent-100 dark:bg-surface-accent-800 px-2 py-0.5 rounded-full"
73
+ >
74
+ {controller.totalCount} assets
75
+ </Typography>
76
+ )}
77
+ </div>
78
+
79
+ <div className="flex items-center gap-2 w-full sm:w-auto">
80
+ <SearchBar
81
+ onTextSearch={handleSearch}
82
+ placeholder="Search assets..."
83
+ className="flex-1 sm:w-64"
84
+ />
85
+
86
+ <div
87
+ className="flex items-center gap-1 border-l border-surface-accent-200 dark:border-surface-accent-700 pl-2 ml-2">
88
+ <Tooltip title="Grid view">
89
+ <IconButton
90
+ onClick={() => setViewMode("grid")}
91
+ className={cls(
92
+ viewMode === "grid" && "bg-surface-accent-100 dark:bg-surface-accent-800"
93
+ )}
94
+ >
95
+ <AppsIcon size="small"/>
96
+ </IconButton>
97
+ </Tooltip>
98
+ <Tooltip title="List view">
99
+ <IconButton
100
+ onClick={() => setViewMode("list")}
101
+ className={cls(
102
+ viewMode === "list" && "bg-surface-accent-100 dark:bg-surface-accent-800"
103
+ )}
104
+ >
105
+ <Icon iconKey="list" size="small"/>
106
+ </IconButton>
107
+ </Tooltip>
108
+ </div>
109
+
110
+ <Tooltip title="Refresh">
111
+ <IconButton onClick={handleRefresh} disabled={controller.loading}>
112
+ <RefreshIcon size="small"/>
113
+ </IconButton>
114
+ </Tooltip>
115
+
116
+ <Button
117
+ variant="filled"
118
+ onClick={handleUploadClick}
119
+ >
120
+ <AddIcon size="small"/>
121
+ Upload
122
+ </Button>
123
+ </div>
124
+ </div>
125
+ </Container>
126
+ </div>
127
+
128
+ {/* Content */}
129
+ <div className="flex-1 overflow-auto">
130
+ <Container maxWidth="6xl" className="py-6">
131
+ {controller.loading && controller.assets.length === 0 ? (
132
+ <div className="flex items-center justify-center h-64">
133
+ <CircularProgress/>
134
+ </div>
135
+ ) : controller.error ? (
136
+ <div className="flex flex-col items-center justify-center h-64 gap-4">
137
+ <Typography className="text-red-500">
138
+ Error loading assets: {controller.error.message}
139
+ </Typography>
140
+ <Button onClick={handleRefresh}>
141
+ Try Again
142
+ </Button>
143
+ </div>
144
+ ) : controller.assets.length === 0 ? (
145
+ <div className="flex flex-col items-center justify-center h-64 gap-4">
146
+ <Typography className="text-surface-accent-500">
147
+ No media assets yet
148
+ </Typography>
149
+ <Button onClick={handleUploadClick}>
150
+ <AddIcon size="small"/>
151
+ Upload your first file
152
+ </Button>
153
+ </div>
154
+ ) : (
155
+ <div className={cls(
156
+ viewMode === "grid"
157
+ ? "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
158
+ : "flex flex-col gap-2"
159
+ )}>
160
+ {controller.assets.map(asset => (
161
+ <MediaAssetCard
162
+ key={asset.id}
163
+ asset={asset}
164
+ viewMode={viewMode}
165
+ onClick={() => controller.selectAsset(asset)}
166
+ selected={controller.selectedAsset?.id === asset.id}
167
+ />
168
+ ))}
169
+ </div>
170
+ )}
171
+ </Container>
172
+ </div>
173
+
174
+ {/* Details Panel */}
175
+ {controller.selectedAsset && (
176
+ <MediaAssetDetails
177
+ asset={controller.selectedAsset}
178
+ onClose={() => controller.selectAsset(undefined)}
179
+ onUpdate={controller.updateAsset}
180
+ onDelete={controller.deleteAsset}
181
+ />
182
+ )}
183
+
184
+ {/* Upload Dialog */}
185
+ <MediaUploadDialog
186
+ open={uploadDialogOpen}
187
+ onClose={() => setUploadDialogOpen(false)}
188
+ onUpload={handleFileSelect}
189
+ maxFileSize={maxFileSize}
190
+ acceptedMimeTypes={acceptedMimeTypes}
191
+ />
192
+ </div>
193
+ );
194
+ }
@@ -0,0 +1,264 @@
1
+ import React, { useCallback, useState } from "react";
2
+ import {
3
+ Button,
4
+ Typography,
5
+ cls,
6
+ Dialog,
7
+ DialogActions,
8
+ DialogContent,
9
+ CloudUploadIcon,
10
+ CircularProgress
11
+ } from "@firecms/ui";
12
+
13
+ export interface MediaUploadDialogProps {
14
+ open: boolean;
15
+ onClose: () => void;
16
+ onUpload: (files: File[]) => Promise<void>;
17
+ maxFileSize?: number;
18
+ acceptedMimeTypes?: string[];
19
+ }
20
+
21
+ /**
22
+ * Dialog component for uploading files to the media library.
23
+ * Supports drag-and-drop and file browser selection.
24
+ */
25
+ export function MediaUploadDialog({
26
+ open,
27
+ onClose,
28
+ onUpload,
29
+ maxFileSize = 52428800, // 50MB default
30
+ acceptedMimeTypes
31
+ }: MediaUploadDialogProps) {
32
+ const [isDragging, setIsDragging] = useState(false);
33
+ const [uploading, setUploading] = useState(false);
34
+ const [error, setError] = useState<string | null>(null);
35
+ const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
36
+
37
+ const validateFiles = useCallback((files: File[]): { valid: File[]; errors: string[] } => {
38
+ const valid: File[] = [];
39
+ const errors: string[] = [];
40
+
41
+ for (const file of files) {
42
+ if (maxFileSize && file.size > maxFileSize) {
43
+ errors.push(`${file.name}: File too large (max ${formatSize(maxFileSize)})`);
44
+ continue;
45
+ }
46
+ if (acceptedMimeTypes && !acceptedMimeTypes.some(type => {
47
+ if (type.endsWith("/*")) {
48
+ return file.type.startsWith(type.slice(0, -1));
49
+ }
50
+ return file.type === type;
51
+ })) {
52
+ errors.push(`${file.name}: File type not allowed`);
53
+ continue;
54
+ }
55
+ valid.push(file);
56
+ }
57
+
58
+ return { valid, errors };
59
+ }, [maxFileSize, acceptedMimeTypes]);
60
+
61
+ const handleDragOver = useCallback((e: React.DragEvent) => {
62
+ e.preventDefault();
63
+ setIsDragging(true);
64
+ }, []);
65
+
66
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
67
+ e.preventDefault();
68
+ setIsDragging(false);
69
+ }, []);
70
+
71
+ const handleDrop = useCallback((e: React.DragEvent) => {
72
+ e.preventDefault();
73
+ setIsDragging(false);
74
+
75
+ const files = Array.from(e.dataTransfer.files);
76
+ const { valid, errors } = validateFiles(files);
77
+
78
+ if (errors.length > 0) {
79
+ setError(errors.join("\n"));
80
+ } else {
81
+ setError(null);
82
+ }
83
+
84
+ setSelectedFiles(prev => [...prev, ...valid]);
85
+ }, [validateFiles]);
86
+
87
+ const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
88
+ const files = Array.from(e.target.files ?? []);
89
+ const { valid, errors } = validateFiles(files);
90
+
91
+ if (errors.length > 0) {
92
+ setError(errors.join("\n"));
93
+ } else {
94
+ setError(null);
95
+ }
96
+
97
+ setSelectedFiles(prev => [...prev, ...valid]);
98
+ }, [validateFiles]);
99
+
100
+ const handleRemoveFile = useCallback((index: number) => {
101
+ setSelectedFiles(prev => prev.filter((_, i) => i !== index));
102
+ }, []);
103
+
104
+ const handleUpload = useCallback(async () => {
105
+ if (selectedFiles.length === 0) return;
106
+
107
+ setUploading(true);
108
+ setError(null);
109
+
110
+ try {
111
+ await onUpload(selectedFiles);
112
+ setSelectedFiles([]);
113
+ onClose();
114
+ } catch (err) {
115
+ setError(err instanceof Error ? err.message : "Upload failed");
116
+ } finally {
117
+ setUploading(false);
118
+ }
119
+ }, [selectedFiles, onUpload, onClose]);
120
+
121
+ const handleClose = useCallback(() => {
122
+ if (!uploading) {
123
+ setSelectedFiles([]);
124
+ setError(null);
125
+ onClose();
126
+ }
127
+ }, [uploading, onClose]);
128
+
129
+ const formatSize = (bytes: number): string => {
130
+ if (bytes < 1024) return `${bytes} B`;
131
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
132
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
133
+ };
134
+
135
+ return (
136
+ <Dialog
137
+ open={open}
138
+ onOpenChange={(open) => !open && handleClose()}
139
+ maxWidth="md"
140
+ >
141
+ <DialogContent className="p-0">
142
+ <div className="p-4 border-b border-surface-accent-200 dark:border-surface-accent-700">
143
+ <Typography variant="h6">
144
+ Upload Files
145
+ </Typography>
146
+ </div>
147
+
148
+ <div className="p-4">
149
+ {/* Drop Zone */}
150
+ <div
151
+ className={cls(
152
+ "border-2 border-dashed rounded-lg p-8",
153
+ "flex flex-col items-center justify-center gap-4",
154
+ "transition-colors duration-150",
155
+ isDragging
156
+ ? "border-primary bg-primary/5"
157
+ : "border-surface-accent-300 dark:border-surface-accent-600",
158
+ "hover:border-primary hover:bg-primary/5",
159
+ "cursor-pointer"
160
+ )}
161
+ onDragOver={handleDragOver}
162
+ onDragLeave={handleDragLeave}
163
+ onDrop={handleDrop}
164
+ onClick={() => document.getElementById("file-upload-input")?.click()}
165
+ >
166
+ <CloudUploadIcon
167
+ size="large"
168
+ className={cls(
169
+ isDragging ? "text-primary" : "text-surface-accent-400"
170
+ )}
171
+ />
172
+ <div className="text-center">
173
+ <Typography variant="body1" className="font-medium">
174
+ Drop files here or click to browse
175
+ </Typography>
176
+ <Typography variant="caption" className="text-surface-accent-500">
177
+ Maximum file size: {formatSize(maxFileSize)}
178
+ </Typography>
179
+ </div>
180
+ <input
181
+ id="file-upload-input"
182
+ type="file"
183
+ multiple
184
+ accept={acceptedMimeTypes?.join(",")}
185
+ onChange={handleFileSelect}
186
+ className="hidden"
187
+ />
188
+ </div>
189
+
190
+ {/* Error Display */}
191
+ {error && (
192
+ <Typography variant="caption" className="text-red-500 mt-2 block whitespace-pre-line">
193
+ {error}
194
+ </Typography>
195
+ )}
196
+
197
+ {/* Selected Files */}
198
+ {selectedFiles.length > 0 && (
199
+ <div className="mt-4 space-y-2">
200
+ <Typography variant="caption" className="text-surface-accent-500">
201
+ Selected files ({selectedFiles.length})
202
+ </Typography>
203
+ <div className="max-h-40 overflow-auto space-y-1">
204
+ {selectedFiles.map((file, index) => (
205
+ <div
206
+ key={`${file.name}-${index}`}
207
+ className={cls(
208
+ "flex items-center justify-between p-2 rounded",
209
+ "bg-surface-accent-50 dark:bg-surface-accent-800"
210
+ )}
211
+ >
212
+ <div className="flex-1 min-w-0 mr-2">
213
+ <Typography variant="body2" className="truncate">
214
+ {file.name}
215
+ </Typography>
216
+ <Typography variant="caption" className="text-surface-accent-500">
217
+ {formatSize(file.size)}
218
+ </Typography>
219
+ </div>
220
+ <Button
221
+ variant="text"
222
+ size="small"
223
+ onClick={(e) => {
224
+ e.stopPropagation();
225
+ handleRemoveFile(index);
226
+ }}
227
+ disabled={uploading}
228
+ >
229
+ Remove
230
+ </Button>
231
+ </div>
232
+ ))}
233
+ </div>
234
+ </div>
235
+ )}
236
+ </div>
237
+ </DialogContent>
238
+
239
+ <DialogActions>
240
+ <Button
241
+ variant="text"
242
+ onClick={handleClose}
243
+ disabled={uploading}
244
+ >
245
+ Cancel
246
+ </Button>
247
+ <Button
248
+ variant="filled"
249
+ onClick={handleUpload}
250
+ disabled={selectedFiles.length === 0 || uploading}
251
+ >
252
+ {uploading ? (
253
+ <>
254
+ <CircularProgress size="smallest" />
255
+ Uploading...
256
+ </>
257
+ ) : (
258
+ `Upload ${selectedFiles.length > 0 ? `(${selectedFiles.length})` : ""}`
259
+ )}
260
+ </Button>
261
+ </DialogActions>
262
+ </Dialog>
263
+ );
264
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./MediaLibraryCard";
2
+ export * from "./MediaLibraryView";
3
+ export * from "./MediaAssetCard";
4
+ export * from "./MediaAssetDetails";
5
+ export * from "./MediaUploadDialog";
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Types
2
+ export * from "./types";
3
+
4
+ // Provider and hooks
5
+ export * from "./MediaManagerProvider";
6
+ export * from "./useMediaManagerController";
7
+ export * from "./useMediaManagerPlugin";
8
+
9
+ // Components
10
+ export * from "./components";
@@ -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";