@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,134 @@
1
+ import React from "react";
2
+ import {
3
+ Card,
4
+ Typography,
5
+ cls,
6
+ ImageIcon,
7
+ DescriptionIcon,
8
+ VideoLibraryIcon,
9
+ AudiotrackIcon,
10
+ CheckIcon,
11
+ defaultBorderMixin
12
+ } from "@firecms/ui";
13
+ import { MediaAsset } from "../types";
14
+
15
+ export interface MediaAssetCardProps {
16
+ asset: MediaAsset;
17
+ viewMode: "grid" | "list";
18
+ onClick: () => void;
19
+ selected?: boolean;
20
+ /** Preferred thumbnail size to display (e.g., "small", "medium") */
21
+ thumbnailSize?: string;
22
+ }
23
+
24
+ /**
25
+ * Card component for displaying a media asset in the grid or list.
26
+ */
27
+ export function MediaAssetCard({
28
+ asset,
29
+ viewMode,
30
+ onClick,
31
+ selected,
32
+ thumbnailSize = "small"
33
+ }: MediaAssetCardProps) {
34
+ const isImage = asset.mimeType.startsWith("image/");
35
+ const isVideo = asset.mimeType.startsWith("video/");
36
+ const isAudio = asset.mimeType.startsWith("audio/");
37
+
38
+ const FileIcon = isImage ? ImageIcon
39
+ : isVideo ? VideoLibraryIcon
40
+ : isAudio ? AudiotrackIcon
41
+ : DescriptionIcon;
42
+
43
+ const formatSize = (bytes: number): string => {
44
+ if (bytes < 1024) return `${bytes} B`;
45
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
46
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
47
+ };
48
+
49
+ // Use thumbnail if available, fallback to downloadURL
50
+ const imageUrl = asset.thumbnails?.[thumbnailSize] ?? asset.downloadURL;
51
+
52
+ const thumbnail = isImage && imageUrl ? (
53
+ <img
54
+ src={imageUrl}
55
+ alt={asset.altText || asset.fileName}
56
+ className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
57
+ loading="lazy"
58
+ />
59
+ ) : (
60
+ <div className="w-full h-full flex items-center justify-center bg-surface-100 dark:bg-surface-800">
61
+ <FileIcon size="large" className="text-surface-400 dark:text-surface-500" />
62
+ </div>
63
+ );
64
+
65
+ if (viewMode === "list") {
66
+ return (
67
+ <div
68
+ className={cls(
69
+ "p-3 cursor-pointer flex items-center gap-3 rounded-lg",
70
+ "hover:bg-surface-100 dark:hover:bg-surface-800",
71
+ "transition-colors duration-150",
72
+ `border ${defaultBorderMixin}`,
73
+ selected && "ring-2 ring-primary bg-primary/5"
74
+ )}
75
+ onClick={onClick}
76
+ >
77
+ <div className="w-12 h-12 rounded-md overflow-hidden flex-shrink-0 bg-surface-100 dark:bg-surface-800">
78
+ {thumbnail}
79
+ </div>
80
+ <div className="flex-1 min-w-0">
81
+ <Typography variant="body2" className="font-medium truncate text-surface-900 dark:text-white">
82
+ {asset.title || asset.fileName}
83
+ </Typography>
84
+ <Typography variant="caption" color="secondary">
85
+ {formatSize(asset.size)} • {asset.mimeType.split("/")[1]?.toUpperCase()}
86
+ </Typography>
87
+ </div>
88
+ {selected && (
89
+ <div className="w-6 h-6 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
90
+ <CheckIcon size="smallest" className="text-white" />
91
+ </div>
92
+ )}
93
+ </div>
94
+ );
95
+ }
96
+
97
+ return (
98
+ <Card
99
+ className={cls(
100
+ "cursor-pointer overflow-hidden group relative",
101
+ "transition-all duration-200",
102
+ "hover:shadow-lg hover:-translate-y-0.5",
103
+ selected && "ring-2 ring-primary"
104
+ )}
105
+ onClick={onClick}
106
+ >
107
+ <div className="aspect-square relative overflow-hidden bg-surface-100 dark:bg-surface-800">
108
+ {thumbnail}
109
+
110
+ {/* Hover overlay */}
111
+ <div className={cls(
112
+ "absolute inset-0 bg-black/0 group-hover:bg-black/20",
113
+ "transition-colors duration-200"
114
+ )} />
115
+
116
+ {/* Selection indicator */}
117
+ {selected && (
118
+ <div className="absolute top-2 right-2 w-6 h-6 rounded-full bg-primary flex items-center justify-center shadow-md">
119
+ <CheckIcon size="smallest" className="text-white" />
120
+ </div>
121
+ )}
122
+ </div>
123
+
124
+ <div className="p-3">
125
+ <Typography variant="body2" className="font-medium truncate text-surface-900 dark:text-white">
126
+ {asset.title || asset.fileName}
127
+ </Typography>
128
+ <Typography variant="caption" color="secondary" className="truncate block mt-0.5">
129
+ {formatSize(asset.size)} • {asset.mimeType.split("/")[1]?.toUpperCase()}
130
+ </Typography>
131
+ </div>
132
+ </Card>
133
+ );
134
+ }
@@ -0,0 +1,353 @@
1
+ import React, { useCallback, useState } from "react";
2
+ import { useCreateFormex } from "@firecms/formex";
3
+ import {
4
+ Button,
5
+ Typography,
6
+ cls,
7
+ CloseIcon,
8
+ DeleteIcon,
9
+ DownloadIcon,
10
+ IconButton,
11
+ TextField,
12
+ Chip,
13
+ CircularProgress,
14
+ Dialog,
15
+ DialogActions,
16
+ DialogContent
17
+ } from "@firecms/ui";
18
+ import { useSnackbarController, useStorageSource } from "@firecms/core";
19
+ import { MediaAsset } from "../types";
20
+
21
+ export interface MediaAssetDetailsProps {
22
+ asset: MediaAsset;
23
+ onClose: () => void;
24
+ onUpdate: (assetId: string, data: Partial<MediaAsset>) => Promise<void>;
25
+ onDelete: (assetId: string) => Promise<void>;
26
+ }
27
+
28
+ /**
29
+ * Side panel component for viewing and editing media asset details.
30
+ */
31
+ export function MediaAssetDetails({
32
+ asset,
33
+ onClose,
34
+ onUpdate,
35
+ onDelete
36
+ }: MediaAssetDetailsProps) {
37
+ const snackbarController = useSnackbarController();
38
+ const storageSource = useStorageSource();
39
+ const [saving, setSaving] = useState(false);
40
+ const [deleting, setDeleting] = useState(false);
41
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
42
+ const [tagInput, setTagInput] = useState("");
43
+
44
+ const { values, setFieldValue, dirty } = useCreateFormex<Partial<MediaAsset>>({
45
+ initialValues: {
46
+ title: asset.title ?? "",
47
+ altText: asset.altText ?? "",
48
+ caption: asset.caption ?? "",
49
+ tags: asset.tags ?? []
50
+ }
51
+ });
52
+
53
+ const handleSave = useCallback(async () => {
54
+ setSaving(true);
55
+ try {
56
+ await onUpdate(asset.id, values);
57
+ snackbarController.open({
58
+ type: "success",
59
+ message: "Asset updated successfully"
60
+ });
61
+ } catch (error) {
62
+ snackbarController.open({
63
+ type: "error",
64
+ message: `Error updating asset: ${error instanceof Error ? error.message : String(error)}`
65
+ });
66
+ } finally {
67
+ setSaving(false);
68
+ }
69
+ }, [asset.id, values, onUpdate, snackbarController]);
70
+
71
+ const handleDelete = useCallback(async () => {
72
+ setDeleting(true);
73
+ try {
74
+ await onDelete(asset.id);
75
+ snackbarController.open({
76
+ type: "success",
77
+ message: "Asset deleted successfully"
78
+ });
79
+ onClose();
80
+ } catch (error) {
81
+ snackbarController.open({
82
+ type: "error",
83
+ message: `Error deleting asset: ${error instanceof Error ? error.message : String(error)}`
84
+ });
85
+ } finally {
86
+ setDeleting(false);
87
+ setDeleteDialogOpen(false);
88
+ }
89
+ }, [asset.id, onDelete, snackbarController, onClose]);
90
+
91
+ const handleDownload = useCallback(async () => {
92
+ try {
93
+ const downloadConfig = await storageSource.getDownloadURL(asset.storagePath, asset.bucket);
94
+ if (downloadConfig.url) {
95
+ window.open(downloadConfig.url, "_blank");
96
+ }
97
+ } catch (error) {
98
+ snackbarController.open({
99
+ type: "error",
100
+ message: "Error getting download URL"
101
+ });
102
+ }
103
+ }, [asset, storageSource, snackbarController]);
104
+
105
+ const handleAddTag = useCallback(() => {
106
+ const tag = tagInput.trim();
107
+ if (tag && !values.tags?.includes(tag)) {
108
+ setFieldValue("tags", [...(values.tags ?? []), tag]);
109
+ setTagInput("");
110
+ }
111
+ }, [tagInput, values.tags, setFieldValue]);
112
+
113
+ const handleRemoveTag = useCallback((tagToRemove: string) => {
114
+ setFieldValue("tags", values.tags?.filter((t: string) => t !== tagToRemove) ?? []);
115
+ }, [values.tags, setFieldValue]);
116
+
117
+ const formatSize = (bytes: number): string => {
118
+ if (bytes < 1024) return `${bytes} B`;
119
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
120
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
121
+ };
122
+
123
+ const formatDate = (date: Date): string => {
124
+ return new Intl.DateTimeFormat(undefined, {
125
+ year: "numeric",
126
+ month: "short",
127
+ day: "numeric"
128
+ }).format(date);
129
+ };
130
+
131
+ const isImage = asset.mimeType.startsWith("image/");
132
+ const isVideo = asset.mimeType.startsWith("video/");
133
+
134
+ return (
135
+ <>
136
+ <div className={cls(
137
+ "fixed inset-y-0 right-0 w-full sm:w-96 lg:w-[480px]",
138
+ "bg-surface-50 dark:bg-surface-900",
139
+ "border-l border-surface-accent-200 dark:border-surface-accent-700",
140
+ "shadow-xl z-50",
141
+ "flex flex-col",
142
+ "animate-slide-in-right"
143
+ )}>
144
+ {/* Header */}
145
+ <div className="flex items-center justify-between p-4 border-b border-surface-accent-200 dark:border-surface-accent-700">
146
+ <Typography variant="subtitle1" className="font-medium truncate flex-1 mr-2">
147
+ {asset.title || asset.fileName}
148
+ </Typography>
149
+ <div className="flex items-center gap-1">
150
+ <IconButton onClick={handleDownload}>
151
+ <DownloadIcon size="small" />
152
+ </IconButton>
153
+ <IconButton
154
+ onClick={() => setDeleteDialogOpen(true)}
155
+ className="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
156
+ >
157
+ <DeleteIcon size="small" />
158
+ </IconButton>
159
+ <IconButton onClick={onClose}>
160
+ <CloseIcon size="small" />
161
+ </IconButton>
162
+ </div>
163
+ </div>
164
+
165
+ {/* Preview */}
166
+ <div className="p-4 bg-surface-accent-100 dark:bg-surface-accent-800 flex items-center justify-center min-h-48 max-h-64">
167
+ {isImage && asset.downloadURL ? (
168
+ <img
169
+ src={asset.downloadURL}
170
+ alt={asset.altText || asset.fileName}
171
+ className="max-w-full max-h-full object-contain"
172
+ />
173
+ ) : isVideo && asset.downloadURL ? (
174
+ <video
175
+ src={asset.downloadURL}
176
+ className="max-w-full max-h-full"
177
+ controls
178
+ />
179
+ ) : (
180
+ <div className="text-surface-accent-400">
181
+ Preview not available
182
+ </div>
183
+ )}
184
+ </div>
185
+
186
+ {/* Content */}
187
+ <div className="flex-1 overflow-auto p-4 space-y-4">
188
+ {/* Metadata */}
189
+ <div className="grid grid-cols-2 gap-3">
190
+ {asset.dimensions && (
191
+ <div>
192
+ <Typography variant="caption" className="text-surface-accent-500">
193
+ Dimensions
194
+ </Typography>
195
+ <Typography variant="body2">
196
+ {asset.dimensions.width} × {asset.dimensions.height} px
197
+ </Typography>
198
+ </div>
199
+ )}
200
+ <div>
201
+ <Typography variant="caption" className="text-surface-accent-500">
202
+ Size
203
+ </Typography>
204
+ <Typography variant="body2">
205
+ {formatSize(asset.size)}
206
+ </Typography>
207
+ </div>
208
+ <div>
209
+ <Typography variant="caption" className="text-surface-accent-500">
210
+ Type
211
+ </Typography>
212
+ <Typography variant="body2">
213
+ {asset.mimeType}
214
+ </Typography>
215
+ </div>
216
+ <div>
217
+ <Typography variant="caption" className="text-surface-accent-500">
218
+ Created
219
+ </Typography>
220
+ <Typography variant="body2">
221
+ {formatDate(asset.createdAt)}
222
+ </Typography>
223
+ </div>
224
+ </div>
225
+
226
+ <hr className="border-surface-accent-200 dark:border-surface-accent-700" />
227
+
228
+ {/* Editable Fields */}
229
+ <TextField
230
+ label="File Name"
231
+ value={asset.fileName}
232
+ disabled
233
+ size="small"
234
+ />
235
+
236
+ <TextField
237
+ label="Title"
238
+ value={values.title ?? ""}
239
+ onChange={(e) => setFieldValue("title", e.target.value)}
240
+ size="small"
241
+ />
242
+
243
+ <div>
244
+ <TextField
245
+ label="Alt Text"
246
+ value={values.altText ?? ""}
247
+ onChange={(e) => setFieldValue("altText", e.target.value)}
248
+ size="small"
249
+ />
250
+ <Typography variant="caption" className="text-surface-accent-500 mt-1">
251
+ Recommended for SEO
252
+ </Typography>
253
+ </div>
254
+
255
+ <TextField
256
+ label="Caption"
257
+ value={values.caption ?? ""}
258
+ onChange={(e) => setFieldValue("caption", e.target.value)}
259
+ size="small"
260
+ multiline
261
+ />
262
+
263
+ {/* Tags */}
264
+ <div>
265
+ <Typography variant="caption" className="text-surface-accent-500 mb-1 block">
266
+ Tags
267
+ </Typography>
268
+ <div className="flex flex-wrap gap-1 mb-2">
269
+ {values.tags?.map((tag: string) => (
270
+ <Chip
271
+ key={tag}
272
+ size="small"
273
+ colorScheme="blueLighter"
274
+ onClick={() => handleRemoveTag(tag)}
275
+ >
276
+ {tag} ×
277
+ </Chip>
278
+ ))}
279
+ </div>
280
+ <div className="flex gap-2">
281
+ <TextField
282
+ placeholder="Add a tag..."
283
+ value={tagInput}
284
+ onChange={(e) => setTagInput(e.target.value)}
285
+ size="small"
286
+ className="flex-1"
287
+ onKeyDown={(e) => {
288
+ if (e.key === "Enter") {
289
+ e.preventDefault();
290
+ handleAddTag();
291
+ }
292
+ }}
293
+ />
294
+ <Button
295
+ variant="text"
296
+ size="small"
297
+ onClick={handleAddTag}
298
+ disabled={!tagInput.trim()}
299
+ >
300
+ Add
301
+ </Button>
302
+ </div>
303
+ </div>
304
+ </div>
305
+
306
+ {/* Footer */}
307
+ <div className="flex-shrink-0 p-4 border-t border-surface-accent-200 dark:border-surface-accent-700">
308
+ <Button
309
+ variant="filled"
310
+ onClick={handleSave}
311
+ disabled={!dirty || saving}
312
+ className="w-full"
313
+ >
314
+ {saving ? <CircularProgress size="small" /> : "Save Changes"}
315
+ </Button>
316
+ </div>
317
+ </div>
318
+
319
+ {/* Delete Confirmation Dialog */}
320
+ <Dialog
321
+ open={deleteDialogOpen}
322
+ onOpenChange={setDeleteDialogOpen}
323
+ >
324
+ <DialogContent>
325
+ <Typography variant="subtitle1" className="font-medium mb-2">
326
+ Delete Asset?
327
+ </Typography>
328
+ <Typography className="text-surface-accent-600 dark:text-surface-accent-400">
329
+ Are you sure you want to delete "{asset.title || asset.fileName}"?
330
+ This action cannot be undone.
331
+ </Typography>
332
+ </DialogContent>
333
+ <DialogActions>
334
+ <Button
335
+ variant="text"
336
+ onClick={() => setDeleteDialogOpen(false)}
337
+ disabled={deleting}
338
+ >
339
+ Cancel
340
+ </Button>
341
+ <Button
342
+ variant="filled"
343
+ color="error"
344
+ onClick={handleDelete}
345
+ disabled={deleting}
346
+ >
347
+ {deleting ? <CircularProgress size="small" /> : "Delete"}
348
+ </Button>
349
+ </DialogActions>
350
+ </Dialog>
351
+ </>
352
+ );
353
+ }
@@ -0,0 +1,55 @@
1
+ import React from "react";
2
+ import { Link } from "react-router-dom";
3
+ import {
4
+ Card,
5
+ Typography,
6
+ cls,
7
+ ImageIcon
8
+ } from "@firecms/ui";
9
+
10
+ export interface MediaLibraryCardProps {
11
+ group?: string;
12
+ context?: unknown;
13
+ }
14
+
15
+ /**
16
+ * Card component displayed on the home page that links to the Media Library.
17
+ */
18
+ export function MediaLibraryCard({ group }: MediaLibraryCardProps) {
19
+ // Only render in the "Media" group
20
+ if (group !== "Media") return null;
21
+
22
+ return (
23
+ <Link to="/media" className="no-underline">
24
+ <Card
25
+ className={cls(
26
+ "p-4 cursor-pointer",
27
+ "hover:bg-surface-accent-100 dark:hover:bg-surface-accent-800",
28
+ "transition-colors duration-200",
29
+ "flex flex-col gap-2"
30
+ )}
31
+ >
32
+ <div className="flex items-center gap-3">
33
+ <div className={cls(
34
+ "w-10 h-10 rounded-lg",
35
+ "bg-primary/10 dark:bg-primary/20",
36
+ "flex items-center justify-center"
37
+ )}>
38
+ <ImageIcon className="text-primary" size="medium" />
39
+ </div>
40
+ <div className="flex-1">
41
+ <Typography variant="subtitle2" className="font-medium">
42
+ Media Library
43
+ </Typography>
44
+ <Typography
45
+ variant="caption"
46
+ className="text-surface-accent-600 dark:text-surface-accent-400"
47
+ >
48
+ Manage images and files
49
+ </Typography>
50
+ </div>
51
+ </div>
52
+ </Card>
53
+ </Link>
54
+ );
55
+ }