@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.
- 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 +1280 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +1276 -0
- package/dist/index.umd.js.map +1 -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 +55 -0
- package/src/components/MediaLibraryView.tsx +194 -0
- package/src/components/MediaUploadDialog.tsx +264 -0
- package/src/components/index.ts +5 -0
- package/src/index.ts +10 -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 +97 -0
|
@@ -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
|
+
}
|