@firecms/core 3.0.0-canary.289 → 3.0.0-canary.290

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.
@@ -83,6 +83,7 @@ function PropertyFieldBindingInternal<T extends CMSType = CMSType, M extends Rec
83
83
  underlyingValueHasChanged,
84
84
  disabled: disabledProp,
85
85
  partOfArray,
86
+ partOfBlock,
86
87
  minimalistView,
87
88
  autoFocus,
88
89
  index,
@@ -93,10 +94,6 @@ function PropertyFieldBindingInternal<T extends CMSType = CMSType, M extends Rec
93
94
  const authController = useAuthController();
94
95
  const customizationController = useCustomizationController();
95
96
 
96
- if(propertyKey === "created_by"){
97
- console.log("Rendering field for created_by", {propertyKey, property, context});
98
- }
99
-
100
97
  return (
101
98
  <Field
102
99
  key={propertyKey}
@@ -168,6 +165,7 @@ function PropertyFieldBindingInternal<T extends CMSType = CMSType, M extends Rec
168
165
  context,
169
166
  disabled,
170
167
  partOfArray,
168
+ partOfBlock,
171
169
  minimalistView,
172
170
  autoFocus,
173
171
  size,
@@ -199,6 +197,7 @@ function FieldInternal<T extends CMSType, CustomProps, M extends Record<string,
199
197
  includeDescription,
200
198
  underlyingValueHasChanged,
201
199
  partOfArray,
200
+ partOfBlock,
202
201
  minimalistView,
203
202
  autoFocus,
204
203
  context,
@@ -261,6 +260,7 @@ function FieldInternal<T extends CMSType, CustomProps, M extends Record<string,
261
260
  disabled: disabled ?? false,
262
261
  underlyingValueHasChanged: underlyingValueHasChanged ?? false,
263
262
  partOfArray: partOfArray ?? false,
263
+ partOfBlock: partOfBlock ?? false,
264
264
  minimalistView: minimalistView ?? false,
265
265
  autoFocus: autoFocus ?? false,
266
266
  customProps: customFieldProps,
@@ -7,9 +7,12 @@ import {
7
7
  Dialog,
8
8
  DialogActions,
9
9
  DialogContent,
10
+ DialogTitle,
10
11
  KeyboardArrowDownIcon,
11
12
  Menu,
12
- MenuItem, VisibilityIcon,
13
+ MenuItem,
14
+ Typography,
15
+ VisibilityIcon,
13
16
  WarningIcon
14
17
  } from "@firecms/ui";
15
18
  import { FormexController } from "@firecms/formex";
@@ -108,17 +111,18 @@ export function LocalChangesMenu<M extends object>({
108
111
  onOpenChange={setPreviewDialogOpen}
109
112
  maxWidth={"4xl"}
110
113
  >
111
- <DialogContent>
112
- <h3 className={"text-2xl mb-4"}>Preview Local Changes</h3>
113
- <p className={"mb-4"}>
114
+ <DialogTitle variant={"h6"}>Preview Local Changes</DialogTitle>
115
+ <DialogContent className={"my-4"}>
116
+ <Typography variant={"body2"} className={"mb-4"}>
114
117
  These are the local changes that will be applied to the form.
115
- </p>
118
+ </Typography>
116
119
  <div className={`border rounded-lg ${defaultBorderMixin}`} style={{
117
120
  maxHeight: 520,
118
121
  overflow: "auto"
119
122
  }}>
120
123
  <div className="p-4">
121
- <PropertyCollectionView data={localChangesData} properties={properties as ResolvedProperties}/>
124
+ <PropertyCollectionView data={localChangesData}
125
+ properties={properties as ResolvedProperties}/>
122
126
  </div>
123
127
  </div>
124
128
  </DialogContent>
@@ -200,6 +200,7 @@ function BlockEntry({
200
200
  context,
201
201
  autoFocus,
202
202
  partOfArray: false,
203
+ partOfBlock: true,
203
204
  minimalistView: true,
204
205
  onPropertyChange: storeProps,
205
206
  }
@@ -149,26 +149,10 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
149
149
  const allPluginGroups = plugins?.flatMap(plugin => plugin.homePage?.navigationEntries ? plugin.homePage.navigationEntries.map(e => e.name) : []) ?? [];
150
150
  const pluginGroups = [...new Set(allPluginGroups)];
151
151
 
152
- const onNavigationEntriesOrderUpdate = useCallback((entries: NavigationGroupMapping[]) => {
153
- if (!plugins) {
154
- return;
155
- }
156
- // remove all groups that have no entries
157
- const filteredEntries = entries.filter(entry => entry.entries.length > 0);
158
- if (plugins.some(plugin => plugin.homePage?.onNavigationEntriesUpdate)) {
159
- plugins.forEach(plugin => {
160
- if (plugin.homePage?.onNavigationEntriesUpdate) {
161
- plugin.homePage.onNavigationEntriesUpdate(filteredEntries);
162
- }
163
- });
164
- }
165
-
166
- }, [plugins]);
167
-
168
- const computeTopNavigation = useCallback((collections: EntityCollection[], views: CMSView[], adminViews: CMSView[], viewsOrder?: string[]): NavigationResult => {
152
+ const computeTopNavigation = useCallback((collections: EntityCollection[], views: CMSView[], adminViews: CMSView[], viewsOrder?: string[], navigationGroupMappingsOverride?: NavigationGroupMapping[], onNavigationEntriesUpdateCallback?: (entries: NavigationGroupMapping[]) => void): NavigationResult => {
169
153
 
170
154
  const finalNavigationGroupMappings: NavigationGroupMapping[] = computeNavigationGroups({
171
- navigationGroupMappings: navigationGroupMappings,
155
+ navigationGroupMappings: navigationGroupMappingsOverride ?? navigationGroupMappings,
172
156
  collections,
173
157
  views,
174
158
  plugins: plugins
@@ -209,7 +193,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
209
193
  ...(views ?? []).reduce((acc, view) => {
210
194
  if (view.hideFromNavigation) return acc;
211
195
 
212
- const pathKey = Array.isArray(view.path) ? view.path[0] : view.path;
196
+ const pathKey = view.path;
213
197
  let groupName = getGroup(view); // Initial group
214
198
 
215
199
  if (finalNavigationGroupMappings) {
@@ -237,7 +221,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
237
221
  ...(adminViews ?? []).reduce((acc, view) => {
238
222
  if (view.hideFromNavigation) return acc;
239
223
 
240
- const pathKey = Array.isArray(view.path) ? view.path[0] : view.path;
224
+ const pathKey = view.path;
241
225
  const groupName = NAVIGATION_ADMIN_GROUP_NAME;
242
226
 
243
227
  acc.push({
@@ -280,21 +264,62 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
280
264
  .map(e => e.group)
281
265
  .filter(Boolean) as string[];
282
266
 
267
+ // Preserve order from finalNavigationGroupMappings (persisted order)
268
+ const groupsFromMappings = finalNavigationGroupMappings.map(g => g.name);
269
+
270
+ // Add any additional groups not in mappings
271
+ const additionalGroups = collectedGroupsFromEntries.filter(g => !groupsFromMappings.includes(g));
272
+
283
273
  const allDefinedGroups = [
284
274
  ...(pluginGroups ?? []),
285
- ...collectedGroupsFromEntries
275
+ ...groupsFromMappings,
276
+ ...additionalGroups
286
277
  ];
287
278
 
288
- const uniqueGroups = [...new Set(allDefinedGroups)]
289
- .sort((a, b) => groupOrderValue(a) - groupOrderValue(b));
279
+ // Remove duplicates while preserving order, then separate admin to the end
280
+ const uniqueGroupsArray = [...new Set(allDefinedGroups)];
281
+ const adminGroups = uniqueGroupsArray.filter(g => g === NAVIGATION_ADMIN_GROUP_NAME);
282
+ const nonAdminGroups = uniqueGroupsArray.filter(g => g !== NAVIGATION_ADMIN_GROUP_NAME);
283
+ const uniqueGroups = [...nonAdminGroups, ...adminGroups];
290
284
 
291
285
  return {
292
286
  allowDragAndDrop: plugins?.some(plugin => plugin.homePage?.allowDragAndDrop) ?? false,
293
287
  navigationEntries,
294
288
  groups: uniqueGroups,
295
- onNavigationEntriesUpdate: onNavigationEntriesOrderUpdate,
289
+ onNavigationEntriesUpdate: onNavigationEntriesUpdateCallback!,
296
290
  };
297
- }, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups, onNavigationEntriesOrderUpdate]);
291
+ }, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups]);
292
+
293
+ const onNavigationEntriesOrderUpdate = useCallback((entries: NavigationGroupMapping[]) => {
294
+ if (!plugins) {
295
+ return;
296
+ }
297
+ // remove all groups that have no entries
298
+ const filteredEntries = entries.filter(entry => entry.entries.length > 0);
299
+
300
+ // Immediately update the local topLevelNavigation with new mappings
301
+ if (collectionsRef.current && viewsRef.current) {
302
+ const updatedNav = computeTopNavigation(
303
+ collectionsRef.current,
304
+ viewsRef.current,
305
+ adminViewsRef.current ?? [],
306
+ viewsOrder,
307
+ filteredEntries,
308
+ onNavigationEntriesOrderUpdate
309
+ );
310
+ setTopLevelNavigation(updatedNav);
311
+ }
312
+
313
+ // Then persist to backend
314
+ if (plugins.some(plugin => plugin.homePage?.onNavigationEntriesUpdate)) {
315
+ plugins.forEach(plugin => {
316
+ if (plugin.homePage?.onNavigationEntriesUpdate) {
317
+ plugin.homePage.onNavigationEntriesUpdate(filteredEntries);
318
+ }
319
+ });
320
+ }
321
+
322
+ }, [plugins, computeTopNavigation, viewsOrder]);
298
323
 
299
324
  const refreshNavigation = useCallback(async () => {
300
325
 
@@ -312,7 +337,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
312
337
  ]
313
338
  );
314
339
 
315
- const computedTopLevelNav = computeTopNavigation(resolvedCollections, resolvedViews, resolvedAdminViews, viewsOrder);
340
+ const computedTopLevelNav = computeTopNavigation(resolvedCollections, resolvedViews, resolvedAdminViews, viewsOrder, undefined, onNavigationEntriesOrderUpdate);
316
341
 
317
342
  let shouldUpdateTopLevelNav = false;
318
343
  if (!areCollectionListsEqual(collectionsRef.current ?? [], resolvedCollections)) {
@@ -717,6 +742,7 @@ function computeNavigationGroups({
717
742
 
718
743
  let result = navigationGroupMappings;
719
744
 
745
+ // Merge plugin navigation entries
720
746
  result = plugins ? plugins?.reduce((acc, plugin) => {
721
747
  if (plugin.homePage?.navigationEntries) {
722
748
  plugin.homePage.navigationEntries.forEach((entry) => {
@@ -739,8 +765,54 @@ function computeNavigationGroups({
739
765
  return acc;
740
766
  }, [...(result ?? [])] as NavigationGroupMapping[]) : result;
741
767
 
768
+ // Track all entries that are already assigned to groups
769
+ const assignedEntries = new Set<string>();
770
+ if (result) {
771
+ result.forEach(group => {
772
+ group.entries.forEach(entry => assignedEntries.add(entry));
773
+ });
774
+ }
775
+
776
+ // Find collections and views that are NOT in any persisted group
777
+ const unassignedGroupMap: Record<string, string[]> = {};
778
+
779
+ // Check collections
780
+ (collections ?? []).forEach(collection => {
781
+ const entry = collection.id ?? collection.path;
782
+ if (!assignedEntries.has(entry)) {
783
+ const groupName = getGroup(collection);
784
+ if (!unassignedGroupMap[groupName]) unassignedGroupMap[groupName] = [];
785
+ unassignedGroupMap[groupName].push(entry);
786
+ }
787
+ });
788
+
789
+ // Check views
790
+ (views ?? []).forEach(view => {
791
+ const entry = view.path;
792
+ if (!assignedEntries.has(entry)) {
793
+ const groupName = getGroup(view);
794
+ if (!unassignedGroupMap[groupName]) unassignedGroupMap[groupName] = [];
795
+ unassignedGroupMap[groupName].push(entry);
796
+ }
797
+ });
798
+
799
+ // Merge unassigned entries into existing groups or create new groups
800
+ Object.entries(unassignedGroupMap).forEach(([groupName, entries]) => {
801
+ if (result) {
802
+ const existingGroup = result.find(g => g.name === groupName);
803
+ if (existingGroup) {
804
+ existingGroup.entries.push(...entries);
805
+ } else {
806
+ result.push({
807
+ name: groupName,
808
+ entries
809
+ });
810
+ }
811
+ }
812
+ });
813
+
742
814
  if (!result) {
743
- // Convert views and collections to navigation group mappings, grouped by their group name
815
+ // No persisted data at all - create from scratch
744
816
  result = [];
745
817
  const groupMap: Record<string, string[]> = {};
746
818
 
@@ -755,12 +827,12 @@ function computeNavigationGroups({
755
827
  // Add views
756
828
  (views ?? []).forEach(view => {
757
829
  const name = getGroup(view);
758
- const entry = Array.isArray(view.path) ? view.path[0] : view.path;
830
+ const entry = view.path;
759
831
  if (!groupMap[name]) groupMap[name] = [];
760
832
  groupMap[name].push(entry);
761
833
  });
762
834
 
763
- // Convert groupMap to initialGroupMappings array
835
+ // Convert groupMap to result array
764
836
  result = Object.entries(groupMap).map(([name, entries]) => ({
765
837
  name,
766
838
  entries
@@ -81,6 +81,11 @@ export interface FieldProps<T extends CMSType = any, CustomProps = any, M extend
81
81
  */
82
82
  partOfArray?: boolean;
83
83
 
84
+ /**
85
+ * Is this field part of a block
86
+ */
87
+ partOfBlock?: boolean;
88
+
84
89
  /**
85
90
  * Display the child properties directly, without being wrapped in an
86
91
  * extendable panel. Note that this will also hide the title of this property.
@@ -220,6 +225,11 @@ export interface PropertyFieldBindingProps<T extends CMSType, M extends Record<s
220
225
  */
221
226
  partOfArray?: boolean;
222
227
 
228
+ /**
229
+ * Is this field part of a block
230
+ */
231
+ partOfBlock?: boolean;
232
+
223
233
  /**
224
234
  * Display the child properties directly, without being wrapped in an
225
235
  * extendable panel. Note that this will also hide the title of this property.
@@ -155,6 +155,10 @@ export interface BaseProperty<T extends CMSType, CustomProps = any> {
155
155
  /**
156
156
  * Should this property be editable. If set to true, the user will be able to modify the property and
157
157
  * save the new config. The saved config will then become the source of truth.
158
+ * Defaults to `true.
159
+ * This props is only useful when you are using the collection editor to modify collection
160
+ * configurations from the CMS itself. You can also use the `editable` prop in the
161
+ * `EntityCollection` interface to disable the edition of all properties in a collection.
158
162
  */
159
163
  editable?: boolean;
160
164
 
@@ -775,8 +779,16 @@ export type StorageConfig = {
775
779
  /**
776
780
  * Use client side image compression and resizing
777
781
  * Will only be applied to these MIME types: image/jpeg, image/png and image/webp
782
+ * @deprecated Use `imageResize` instead
778
783
  */
779
- imageCompression?: ImageCompression;
784
+ imageCompression?: ImageResize;
785
+
786
+ /**
787
+ * Advanced image resizing and cropping configuration.
788
+ * Applied before upload to optimize storage and bandwidth.
789
+ * Only applies to image MIME types: image/jpeg, image/png, image/webp
790
+ */
791
+ imageResize?: ImageResize;
780
792
 
781
793
  /**
782
794
  * Specific metadata set in your uploaded file.
@@ -913,19 +925,36 @@ export type FileType =
913
925
  | "font/*"
914
926
  | string;
915
927
 
916
- export interface ImageCompression {
928
+ export interface ImageResize {
917
929
  /**
918
- * New image max height (ratio is preserved)
930
+ * Maximum width in pixels. Image will be scaled down proportionally if wider.
931
+ */
932
+ maxWidth?: number;
933
+
934
+ /**
935
+ * Maximum height in pixels. Image will be scaled down proportionally if taller.
919
936
  */
920
937
  maxHeight?: number;
921
938
 
922
939
  /**
923
- * New image max width (ratio is preserved)
940
+ * Resize mode determines how the image fits within maxWidth/maxHeight bounds.
941
+ * - `contain`: Scale down to fit within bounds, preserving aspect ratio (default)
942
+ * - `cover`: Scale to fill bounds, preserving aspect ratio (may crop)
924
943
  */
925
- maxWidth?: number;
944
+ mode?: 'contain' | 'cover';
945
+
946
+ /**
947
+ * Output format for the resized image.
948
+ * - `original`: Keep the original format (default)
949
+ * - `jpeg`: Convert to JPEG
950
+ * - `png`: Convert to PNG
951
+ * - `webp`: Convert to WebP
952
+ */
953
+ format?: 'original' | 'jpeg' | 'png' | 'webp';
926
954
 
927
955
  /**
928
- * A number between 0 and 100. Used for the JPEG compression.(if no compress is needed, just set it to 100)
956
+ * Quality for lossy formats (JPEG, WebP). Number between 0 and 100.
957
+ * Higher is better quality but larger file size. Defaults to 80.
929
958
  */
930
959
  quality?: number;
931
960
  }
@@ -78,7 +78,6 @@ export function mergeCollection(target: EntityCollection,
78
78
  modifyCollection?: (props: ModifyCollectionProps) => EntityCollection | void
79
79
  ): EntityCollection {
80
80
 
81
-
82
81
  const subcollectionsMerged = joinCollectionLists(
83
82
  target?.subcollections ?? [],
84
83
  source?.subcollections ?? [],
@@ -132,8 +131,9 @@ function mergePropertyOrBuilder(target: PropertyOrBuilder, source: PropertyOrBui
132
131
  return target;
133
132
  } else {
134
133
  const mergedProperty = mergeDeep(target, source);
135
- const targetEditable = Boolean(target.editable);
136
- const sourceEditable = Boolean(source.editable);
134
+ const targetEditable = target.editable === undefined ? true : Boolean(target.editable);
135
+ const sourceEditable = source.editable === undefined ? true : Boolean(source.editable);
136
+
137
137
  if (source.dataType === "map" && source.properties) {
138
138
  const targetProperties = ("properties" in target ? target.properties : {}) as PropertiesOrBuilders;
139
139
  const sourceProperties = ("properties" in source ? source.properties : {}) as PropertiesOrBuilders;
@@ -13,25 +13,3 @@ export function makePropertiesEditable(properties: Properties) {
13
13
  });
14
14
  return properties;
15
15
  }
16
-
17
- export function makePropertiesNonEditable(properties: PropertiesOrBuilders): PropertiesOrBuilders {
18
- return Object.entries(properties).reduce((acc, [key, property]) => {
19
- if (!isPropertyBuilder(property) && property.dataType === "map" && property.properties) {
20
- const updated = {
21
- ...property,
22
- properties: makePropertiesNonEditable(property.properties as PropertiesOrBuilders)
23
- };
24
- acc[key] = updated;
25
- }
26
- if (isPropertyBuilder(property)) {
27
- acc[key] = property;
28
- } else {
29
- acc[key] = {
30
- ...property,
31
- editable: false
32
- };
33
- }
34
- return acc;
35
- }, {} as PropertiesOrBuilders);
36
-
37
- }
@@ -1,10 +1,10 @@
1
- import Resizer from "react-image-file-resizer";
1
+ import Compressor from "compressorjs";
2
2
  import equal from "react-fast-compare";
3
3
 
4
4
  import {
5
5
  ArrayProperty,
6
6
  EntityValues,
7
- ImageCompression,
7
+ ImageResize,
8
8
  Property,
9
9
  PropertyOrBuilder,
10
10
  ResolvedArrayProperty,
@@ -76,7 +76,9 @@ export function useStorageUploadController<M extends object>({
76
76
  const metadata: Record<string, any> | undefined = storage?.metadata;
77
77
  const size = multipleFilesSupported ? "medium" : "large";
78
78
 
79
- const compression: ImageCompression | undefined = storage?.imageCompression;
79
+ // Support both new imageResize and deprecated imageCompression
80
+ const imageResize = storage?.imageResize;
81
+ const legacyCompression = storage?.imageCompression;
80
82
 
81
83
  const internalInitialValue: StorageFieldItem[] =
82
84
  getInternalInitialValue(multipleFilesSupported, value, metadata, size);
@@ -169,6 +171,14 @@ export function useStorageUploadController<M extends object>({
169
171
  }
170
172
  }, [internalValue, multipleFilesSupported, onChange, storage, storageSource]);
171
173
 
174
+ const onFileUploadError = useCallback((entry: StorageFieldItem) => {
175
+ console.debug("onFileUploadError", entry);
176
+
177
+ // Remove the failed entry from internalValue
178
+ const newValue = internalValue.filter(item => item.id !== entry.id);
179
+ setInternalValue(newValue);
180
+ }, [internalValue]);
181
+
172
182
  const onFilesAdded = useCallback(async (acceptedFiles: File[]) => {
173
183
 
174
184
  if (!acceptedFiles.length || disabled)
@@ -193,8 +203,8 @@ export function useStorageUploadController<M extends object>({
193
203
  if (multipleFilesSupported) {
194
204
  newInternalValue = [...internalValue,
195
205
  ...(await Promise.all(acceptedFiles.map(async file => {
196
- if (compression && compressionFormat(file)) {
197
- file = await resizeAndCompressImage(file, compression)
206
+ if ((imageResize || legacyCompression) && isImageFile(file)) {
207
+ file = await resizeImage(file, imageResize, legacyCompression);
198
208
  }
199
209
 
200
210
  return {
@@ -206,9 +216,9 @@ export function useStorageUploadController<M extends object>({
206
216
  } as StorageFieldItem;
207
217
  })))];
208
218
  } else {
209
- let file = acceptedFiles[0]
210
- if (compression && compressionFormat(file)) {
211
- file = await resizeAndCompressImage(file, compression)
219
+ let file = acceptedFiles[0];
220
+ if ((imageResize || legacyCompression) && isImageFile(file)) {
221
+ file = await resizeImage(file, imageResize, legacyCompression);
212
222
  }
213
223
 
214
224
  newInternalValue = [{
@@ -223,7 +233,7 @@ export function useStorageUploadController<M extends object>({
223
233
  // Remove either storage path or file duplicates
224
234
  newInternalValue = removeDuplicates(newInternalValue);
225
235
  setInternalValue(newInternalValue);
226
- }, [disabled, fileNameBuilder, internalValue, metadata, multipleFilesSupported, size]);
236
+ }, [disabled, fileNameBuilder, internalValue, metadata, multipleFilesSupported, size, imageResize, legacyCompression]);
227
237
 
228
238
  return {
229
239
  internalValue,
@@ -232,6 +242,7 @@ export function useStorageUploadController<M extends object>({
232
242
  fileNameBuilder,
233
243
  storagePathBuilder,
234
244
  onFileUploadComplete,
245
+ onFileUploadError,
235
246
  onFilesAdded,
236
247
  multipleFilesSupported
237
248
  }
@@ -276,31 +287,57 @@ function getRandomId() {
276
287
  return Math.floor(Math.random() * Math.floor(Number.MAX_SAFE_INTEGER));
277
288
  }
278
289
 
279
- const supportedTypes: Record<string, string> = {
280
- "image/jpeg": "JPEG",
281
- "image/png": "PNG",
282
- "image/webp": "WEBP"
290
+ /**
291
+ * Check if a file is an image type supported for resizing
292
+ */
293
+ function isImageFile(file: File): boolean {
294
+ return file.type === "image/jpeg" ||
295
+ file.type === "image/png" ||
296
+ file.type === "image/webp";
283
297
  }
284
- const compressionFormat = (file: File) => supportedTypes[file.type] ? supportedTypes[file.type] : null;
285
-
286
- const defaultQuality = 100;
287
- const resizeAndCompressImage = (file: File, compression: ImageCompression) => new Promise<File>((resolve) => {
288
298
 
289
- const inputQuality = compression.quality === undefined ? defaultQuality : compression.quality;
290
- const quality = inputQuality >= 0 ? inputQuality <= 100 ? inputQuality : 100 : 100;
291
-
292
- const format = compressionFormat(file);
293
- if (!format) {
294
- throw Error("resizeAndCompressImage: Unsupported image format");
299
+ /**
300
+ * Resize and compress an image using compressorjs.
301
+ * Supports both the new imageResize API and legacy imageCompression for backward compatibility.
302
+ */
303
+ async function resizeImage(
304
+ file: File,
305
+ imageResize?: StorageConfig["imageResize"],
306
+ legacyCompression?: ImageResize
307
+ ): Promise<File> {
308
+ // Determine configuration (new API takes precedence)
309
+ const maxWidth = imageResize?.maxWidth ?? legacyCompression?.maxWidth;
310
+ const maxHeight = imageResize?.maxHeight ?? legacyCompression?.maxHeight;
311
+ const quality = (imageResize?.quality ?? legacyCompression?.quality ?? 80) / 100;
312
+ const mode = imageResize?.mode ?? "contain";
313
+
314
+ // Determine output format
315
+ let mimeType = file.type;
316
+ if (imageResize?.format && imageResize.format !== "original") {
317
+ mimeType = `image/${imageResize.format}`;
295
318
  }
296
- Resizer.imageFileResizer(
297
- file,
298
- compression.maxWidth || Number.MAX_VALUE,
299
- compression.maxHeight || Number.MAX_VALUE,
300
- format,
301
- quality,
302
- 0,
303
- (file: string | Blob | File | ProgressEvent<FileReader>) => resolve(file as File),
304
- "file"
305
- )
306
- });
319
+
320
+ return new Promise<File>((resolve, reject) => {
321
+ new Compressor(file, {
322
+ quality,
323
+ maxWidth,
324
+ maxHeight,
325
+ mimeType,
326
+ // Use cover mode if specified (crops to fit)
327
+ // Otherwise use contain mode (scales to fit)
328
+ ...(mode === "cover" || mode === undefined ? {
329
+ width: maxWidth,
330
+ height: maxHeight,
331
+ resize: "cover" as const
332
+ } : {}),
333
+ success: (result) => {
334
+ const compressedFile = new File([result], file.name, {
335
+ type: result.type,
336
+ lastModified: Date.now(),
337
+ });
338
+ resolve(compressedFile);
339
+ },
340
+ error: reject,
341
+ });
342
+ });
343
+ }