@strapi/upload 5.60.0 → 5.61.0

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.
Files changed (91) hide show
  1. package/dist/admin/components/AssetPickerDialog/MediaLibraryPickerDialog.js +29 -0
  2. package/dist/admin/components/AssetPickerDialog/MediaLibraryPickerDialog.js.map +1 -0
  3. package/dist/admin/components/AssetPickerDialog/MediaLibraryPickerDialog.mjs +27 -0
  4. package/dist/admin/components/AssetPickerDialog/MediaLibraryPickerDialog.mjs.map +1 -0
  5. package/dist/admin/components/Drawer.js +12 -7
  6. package/dist/admin/components/Drawer.js.map +1 -1
  7. package/dist/admin/components/Drawer.mjs +12 -7
  8. package/dist/admin/components/Drawer.mjs.map +1 -1
  9. package/dist/admin/components/EditorFileDropZone/EditorFileDropZone.js +188 -0
  10. package/dist/admin/components/EditorFileDropZone/EditorFileDropZone.js.map +1 -0
  11. package/dist/admin/components/EditorFileDropZone/EditorFileDropZone.mjs +186 -0
  12. package/dist/admin/components/EditorFileDropZone/EditorFileDropZone.mjs.map +1 -0
  13. package/dist/admin/components/MediaLibraryInput/AddAssetMenu.js +5 -1
  14. package/dist/admin/components/MediaLibraryInput/AddAssetMenu.js.map +1 -1
  15. package/dist/admin/components/MediaLibraryInput/AddAssetMenu.mjs +5 -1
  16. package/dist/admin/components/MediaLibraryInput/AddAssetMenu.mjs.map +1 -1
  17. package/dist/admin/components/MediaLibraryInput/MediaLibraryInput.js +37 -27
  18. package/dist/admin/components/MediaLibraryInput/MediaLibraryInput.js.map +1 -1
  19. package/dist/admin/components/MediaLibraryInput/MediaLibraryInput.mjs +37 -27
  20. package/dist/admin/components/MediaLibraryInput/MediaLibraryInput.mjs.map +1 -1
  21. package/dist/admin/components/MediaLibraryInput/openAssetDetails.js +46 -0
  22. package/dist/admin/components/MediaLibraryInput/openAssetDetails.js.map +1 -0
  23. package/dist/admin/components/MediaLibraryInput/openAssetDetails.mjs +43 -0
  24. package/dist/admin/components/MediaLibraryInput/openAssetDetails.mjs.map +1 -0
  25. package/dist/admin/index.js +10 -1
  26. package/dist/admin/index.js.map +1 -1
  27. package/dist/admin/index.mjs +10 -1
  28. package/dist/admin/index.mjs.map +1 -1
  29. package/dist/admin/pages/Assets/AssetsPage.js +1 -1
  30. package/dist/admin/pages/Assets/AssetsPage.js.map +1 -1
  31. package/dist/admin/pages/Assets/AssetsPage.mjs +2 -2
  32. package/dist/admin/pages/Assets/AssetsPage.mjs.map +1 -1
  33. package/dist/admin/pages/Assets/components/AssetDetails/AssetCropEditor.mjs +2 -2
  34. package/dist/admin/pages/Assets/components/AssetDetails/AssetDetailsDrawer.js +181 -65
  35. package/dist/admin/pages/Assets/components/AssetDetails/AssetDetailsDrawer.js.map +1 -1
  36. package/dist/admin/pages/Assets/components/AssetDetails/AssetDetailsDrawer.mjs +184 -69
  37. package/dist/admin/pages/Assets/components/AssetDetails/AssetDetailsDrawer.mjs.map +1 -1
  38. package/dist/admin/pages/Assets/components/AssetDetails/AssetPreview.mjs +1 -1
  39. package/dist/admin/pages/Assets/hooks/useIsAssetDetailsOpen.js +2 -6
  40. package/dist/admin/pages/Assets/hooks/useIsAssetDetailsOpen.js.map +1 -1
  41. package/dist/admin/pages/Assets/hooks/useIsAssetDetailsOpen.mjs +2 -6
  42. package/dist/admin/pages/Assets/hooks/useIsAssetDetailsOpen.mjs.map +1 -1
  43. package/dist/admin/src/components/AssetPickerDialog/MediaLibraryPickerDialog.d.ts +19 -0
  44. package/dist/admin/src/components/Drawer.d.ts +8 -0
  45. package/dist/admin/src/components/EditorFileDropZone/EditorFileDropZone.d.ts +21 -0
  46. package/dist/admin/src/components/MediaLibraryInput/AddAssetMenu.d.ts +7 -1
  47. package/dist/admin/src/components/MediaLibraryInput/openAssetDetails.d.ts +10 -0
  48. package/dist/admin/src/pages/Assets/components/AssetDetails/AssetDetailsDrawer.d.ts +41 -5
  49. package/dist/admin/src/pages/Assets/hooks/useIsAssetDetailsOpen.d.ts +2 -6
  50. package/dist/admin/translations/en.json.js +4 -0
  51. package/dist/admin/translations/en.json.js.map +1 -1
  52. package/dist/admin/translations/en.json.mjs +4 -0
  53. package/dist/admin/translations/en.json.mjs.map +1 -1
  54. package/dist/server/audit-logs.js +264 -0
  55. package/dist/server/audit-logs.js.map +1 -0
  56. package/dist/server/audit-logs.mjs +255 -0
  57. package/dist/server/audit-logs.mjs.map +1 -0
  58. package/dist/server/bootstrap.js +4 -0
  59. package/dist/server/bootstrap.js.map +1 -1
  60. package/dist/server/bootstrap.mjs +4 -0
  61. package/dist/server/bootstrap.mjs.map +1 -1
  62. package/dist/server/controllers/admin-folder-file.js +23 -1
  63. package/dist/server/controllers/admin-folder-file.js.map +1 -1
  64. package/dist/server/controllers/admin-folder-file.mjs +23 -1
  65. package/dist/server/controllers/admin-folder-file.mjs.map +1 -1
  66. package/dist/server/services/folder.js +48 -1
  67. package/dist/server/services/folder.js.map +1 -1
  68. package/dist/server/services/folder.mjs +48 -1
  69. package/dist/server/services/folder.mjs.map +1 -1
  70. package/dist/server/services/image-manipulation.js +12 -5
  71. package/dist/server/services/image-manipulation.js.map +1 -1
  72. package/dist/server/services/image-manipulation.mjs +12 -5
  73. package/dist/server/services/image-manipulation.mjs.map +1 -1
  74. package/dist/server/services/upload.js +100 -16
  75. package/dist/server/services/upload.js.map +1 -1
  76. package/dist/server/services/upload.mjs +102 -18
  77. package/dist/server/services/upload.mjs.map +1 -1
  78. package/dist/server/src/audit-logs.d.ts +142 -0
  79. package/dist/server/src/audit-logs.d.ts.map +1 -0
  80. package/dist/server/src/bootstrap.d.ts.map +1 -1
  81. package/dist/server/src/controllers/admin-folder-file.d.ts.map +1 -1
  82. package/dist/server/src/index.d.ts +3 -3
  83. package/dist/server/src/index.d.ts.map +1 -1
  84. package/dist/server/src/mcp/ambient-instance.d.ts.map +1 -1
  85. package/dist/server/src/services/folder.d.ts.map +1 -1
  86. package/dist/server/src/services/image-manipulation.d.ts +3 -3
  87. package/dist/server/src/services/image-manipulation.d.ts.map +1 -1
  88. package/dist/server/src/services/index.d.ts +3 -3
  89. package/dist/server/src/services/index.d.ts.map +1 -1
  90. package/dist/server/src/services/upload.d.ts.map +1 -1
  91. package/package.json +9 -16
@@ -1,10 +1,10 @@
1
1
  import { jsx, jsxs } from 'react/jsx-runtime';
2
2
  import * as React from 'react';
3
3
  import { useNotification } from '@strapi/admin/strapi-admin';
4
- import { Portal, FocusTrap, Typography, IconButton, Flex, Field, Button, Box, NumberInput } from '@strapi/design-system';
4
+ import { Flex, Box, Field, NumberInput, Portal, FocusTrap, Typography, IconButton, Button } from '@strapi/design-system';
5
5
  import { Crop, ArrowClockwise, ArrowsHorizontal, ArrowsVertical, Link } from '@strapi/icons';
6
6
  import { useIntl } from 'react-intl';
7
- import { useTheme, styled } from 'styled-components';
7
+ import { styled, useTheme } from 'styled-components';
8
8
  import { prefixFileUrlWithBackendUrl } from '../../../../utils/files.mjs';
9
9
  import { getTranslationKey } from '../../../../utils/translations.mjs';
10
10
  import { useCropImg, resolveCornerResize } from './useCropImg.mjs';
@@ -64,35 +64,12 @@ const useAssetOperation = ()=>{
64
64
  return ctx;
65
65
  };
66
66
  /* -------------------------------------------------------------------------------------------------
67
- * useAssetDetailsParam - sync drawer visibility with URL ?{ASSET_DETAILS_URL_PARAM}={id}
67
+ * useAssetDetailsParam - read and write the open asset in URL ?{ASSET_DETAILS_URL_PARAM}={id}
68
68
  * -----------------------------------------------------------------------------------------------*/ const useAssetDetailsParam = ()=>{
69
69
  const [{ query }, setQuery] = strapiAdmin.useQueryParams();
70
70
  const assetId = useIsAssetDetailsOpen.parseAssetDetailsId(query?.[useIsAssetDetailsOpen.ASSET_DETAILS_URL_PARAM]);
71
- const hasValidId = assetId !== null;
72
71
  // Closing is driven by removing the URL param (a navigation), so navigation
73
- // guards like <Blocker> can intercept it. `isMounted` keeps the drawer in the
74
- // tree through the slide-out: it stays true once opened and only flips false
75
- // when the close animation actually ends (see onCloseAnimationEnd), so the
76
- // close duration lives entirely in CSS — no JS timer.
77
- const [isMounted, setIsMounted] = React__namespace.useState(hasValidId);
78
- const displayAssetId = React__namespace.useRef(null);
79
- React__namespace.useEffect(()=>{
80
- if (hasValidId) {
81
- displayAssetId.current = assetId;
82
- setIsMounted(true);
83
- }
84
- }, [
85
- hasValidId,
86
- assetId
87
- ]);
88
- const onCloseAnimationEnd = React__namespace.useCallback((event)=>{
89
- // Ignore animations bubbling up from descendants, and the slide-in.
90
- if (event.target === event.currentTarget && !hasValidId) {
91
- setIsMounted(false);
92
- }
93
- }, [
94
- hasValidId
95
- ]);
72
+ // guards like <Blocker> can intercept it.
96
73
  const openDetails = React__namespace.useCallback((id)=>{
97
74
  setQuery(strapiAdmin.withEncodedUserParams(query, {
98
75
  [useIsAssetDetailsOpen.ASSET_DETAILS_URL_PARAM]: String(id)
@@ -110,14 +87,44 @@ const useAssetOperation = ()=>{
110
87
  setQuery
111
88
  ]);
112
89
  return {
113
- assetId: hasValidId ? assetId : displayAssetId.current,
114
- isVisible: hasValidId,
115
- shouldRenderDrawer: isMounted,
116
- onCloseAnimationEnd,
90
+ assetId,
117
91
  openDetails,
118
92
  closeDetails
119
93
  };
120
94
  };
95
+ /* -------------------------------------------------------------------------------------------------
96
+ * useDrawerPresence - keep the drawer mounted through its slide-out
97
+ * -----------------------------------------------------------------------------------------------*/ const useDrawerPresence = (assetId)=>{
98
+ const isVisible = assetId !== null;
99
+ // `isMounted` stays true once opened and only flips false when the close
100
+ // animation actually ends (see onCloseAnimationEnd), so the close duration
101
+ // lives entirely in CSS — no JS timer. The last id is kept so the content
102
+ // doesn't go blank mid-slide.
103
+ const [isMounted, setIsMounted] = React__namespace.useState(isVisible);
104
+ const displayAssetId = React__namespace.useRef(assetId);
105
+ React__namespace.useEffect(()=>{
106
+ if (assetId !== null) {
107
+ displayAssetId.current = assetId;
108
+ setIsMounted(true);
109
+ }
110
+ }, [
111
+ assetId
112
+ ]);
113
+ const onCloseAnimationEnd = React__namespace.useCallback((event)=>{
114
+ // Ignore animations bubbling up from descendants, and the slide-in.
115
+ if (event.target === event.currentTarget && !isVisible) {
116
+ setIsMounted(false);
117
+ }
118
+ }, [
119
+ isVisible
120
+ ]);
121
+ return {
122
+ displayAssetId: isVisible ? assetId : displayAssetId.current,
123
+ isVisible,
124
+ shouldRender: isMounted,
125
+ onCloseAnimationEnd
126
+ };
127
+ };
121
128
  const DetailItemContainer = styledComponents.styled(designSystem.Flex)`
122
129
  flex: 0 0 calc(50% - ${({ theme })=>theme.spaces[2]});
123
130
  `;
@@ -213,13 +220,14 @@ const DetailField = ({ name, label, required, disabled })=>{
213
220
  const { formatMessage } = reactIntl.useIntl();
214
221
  const field = strapiAdmin.useField(name);
215
222
  const isSubmitting = strapiAdmin.useForm('DetailField', (state)=>state.isSubmitting);
216
- // The form context (use-context-selector) echoes changes back
217
- // asynchronously — driving the input straight from `field.value` makes
218
- // React rewrite the DOM value after the keystroke, which throws the cursor
219
- // to the end of the field. The input is therefore controlled by local state
220
- // (synchronous, cursor-safe) and the form follows; the effect below only
221
- // matters for EXTERNAL value changes (discard/reset), where a cursor jump
222
- // is fine because the user isn't typing.
223
+ // The input is controlled by local state (synchronous, cursor-safe) and the
224
+ // form follows. This was needed when the form context echoed changes back
225
+ // asynchronously: driving the input straight from `field.value` made React
226
+ // rewrite the DOM value after the keystroke, which threw the cursor to the
227
+ // end of the field. The form context now updates synchronously, but the
228
+ // local state is kept as a safe default. The effect below only matters for
229
+ // EXTERNAL value changes (discard/reset), where a cursor jump is fine
230
+ // because the user isn't typing.
223
231
  const fieldValue = field.value ?? '';
224
232
  const [value, setValue] = React__namespace.useState(fieldValue);
225
233
  React__namespace.useEffect(()=>{
@@ -562,7 +570,26 @@ const AssetImageActions = ({ onCrop })=>{
562
570
  })
563
571
  });
564
572
  };
565
- const AssetDetails = ({ asset, closeDetails })=>{
573
+ /* -------------------------------------------------------------------------------------------------
574
+ * AssetDetails
575
+ * -----------------------------------------------------------------------------------------------*/ /**
576
+ * Writes whether the form has unsaved edits into the drawer's ref. Only the
577
+ * drawer's close handlers read it, so a ref rather than state: toggling dirty
578
+ * doesn't re-render the drawer. A component, not a hook, because the form state
579
+ * is only reachable inside <Form>'s render prop.
580
+ */ const ReportUnsavedEdits = ({ target, hasUnsavedEdits })=>{
581
+ React__namespace.useLayoutEffect(()=>{
582
+ target.current = hasUnsavedEdits;
583
+ return ()=>{
584
+ target.current = false;
585
+ };
586
+ }, [
587
+ target,
588
+ hasUnsavedEdits
589
+ ]);
590
+ return null;
591
+ };
592
+ const AssetDetails = ({ asset, closeDetails, hasUnsavedEditsRef, guardNavigation = false, onAssetChange, onAssetDelete, trackedLocation = useTracking.MEDIA_LIBRARY_LOCATION })=>{
566
593
  const { formatMessage, formatDate } = reactIntl.useIntl();
567
594
  const getErrorMessage = useApiErrorMessage.useApiErrorMessage();
568
595
  const { canCreate, canUpdate, canDownload, canCopyLink } = useMediaLibraryPermissions.useMediaLibraryPermissions();
@@ -587,13 +614,21 @@ const AssetDetails = ({ asset, closeDetails })=>{
587
614
  // own handlers (replace, update) read like the consumers do.
588
615
  const notify = React__namespace.useCallback((toast)=>setDrawerToast(toast), []);
589
616
  const isImage = asset.mime?.includes(enums.ASSET_TYPES.Image);
590
- const initialValues = {
591
- name: asset.name ?? '',
592
- caption: asset.caption ?? '',
593
- alternativeText: asset.alternativeText ?? '',
594
- folder: typeof asset.folder === 'object' && asset.folder !== null ? asset.folder.id ?? null : asset.folder ?? null
595
- };
596
- const handleSubmit = async (values)=>{
617
+ // Stable until the asset changes: <Form> resets its values whenever this
618
+ // object does, which on every render would throw away the baseline a save
619
+ // sets and show the pre-save values until the refetch lands.
620
+ const initialValues = React__namespace.useMemo(()=>({
621
+ name: asset.name ?? '',
622
+ caption: asset.caption ?? '',
623
+ alternativeText: asset.alternativeText ?? '',
624
+ folder: typeof asset.folder === 'object' && asset.folder !== null ? asset.folder.id ?? null : asset.folder ?? null
625
+ }), [
626
+ asset.name,
627
+ asset.caption,
628
+ asset.alternativeText,
629
+ asset.folder
630
+ ]);
631
+ const handleSubmit = async (values, { resetForm })=>{
597
632
  const fileInfo = {
598
633
  name: values.name,
599
634
  caption: values.caption,
@@ -614,8 +649,13 @@ const AssetDetails = ({ asset, closeDetails })=>{
614
649
  });
615
650
  return;
616
651
  }
652
+ // What was saved is the new baseline. Otherwise the form stays "modified"
653
+ // until the refetched asset resets it, and closing in that gap asks about
654
+ // edits that are already saved.
655
+ resetForm(values);
656
+ onAssetChange?.(res.data);
617
657
  trackUsage('didEditMediaLibraryElements', {
618
- location: useTracking.MEDIA_LIBRARY_LOCATION,
658
+ location: trackedLocation,
619
659
  // Legacy sends the mime top-level type (image/video/audio/application…).
620
660
  type: asset.mime?.split('/')[0],
621
661
  changeLocation: values.folder !== initialValues.folder
@@ -648,8 +688,9 @@ const AssetDetails = ({ asset, closeDetails })=>{
648
688
  });
649
689
  return;
650
690
  }
691
+ onAssetChange?.(res.data);
651
692
  trackUsage('didReplaceMedia', {
652
- location: useTracking.MEDIA_LIBRARY_LOCATION
693
+ location: trackedLocation
653
694
  });
654
695
  notify({
655
696
  type: 'success',
@@ -664,7 +705,9 @@ const AssetDetails = ({ asset, closeDetails })=>{
664
705
  formatMessage,
665
706
  getErrorMessage,
666
707
  notify,
708
+ onAssetChange,
667
709
  replaceMutation,
710
+ trackedLocation,
668
711
  trackUsage
669
712
  ]);
670
713
  // Owns the delete: on error notify in-drawer (drawer stays), on success fire
@@ -690,6 +733,7 @@ const AssetDetails = ({ asset, closeDetails })=>{
690
733
  folderName
691
734
  })
692
735
  });
736
+ onAssetDelete?.(asset.id);
693
737
  closeDetails();
694
738
  }, [
695
739
  asset.id,
@@ -699,6 +743,7 @@ const AssetDetails = ({ asset, closeDetails })=>{
699
743
  formatMessage,
700
744
  getErrorMessage,
701
745
  notify,
746
+ onAssetDelete,
702
747
  toggleNotification
703
748
  ]);
704
749
  const notifyCropError = (error)=>{
@@ -726,8 +771,9 @@ const AssetDetails = ({ asset, closeDetails })=>{
726
771
  notifyCropError(res.error);
727
772
  return;
728
773
  }
774
+ onAssetChange?.(res.data);
729
775
  trackUsage('didCropFile', {
730
- location: useTracking.MEDIA_LIBRARY_LOCATION,
776
+ location: trackedLocation,
731
777
  duplicatedFile: false
732
778
  });
733
779
  notify({
@@ -739,6 +785,7 @@ const AssetDetails = ({ asset, closeDetails })=>{
739
785
  });
740
786
  };
741
787
  // Save as copy: upload the cropped file as a new asset in the same folder.
788
+ // The original is untouched, so `onAssetChange` stays silent.
742
789
  const handleCropSaveAsCopy = async (file, focalPoint)=>{
743
790
  setIsCropOpen(false);
744
791
  // Silent single-file upload (no global progress dialog). Carry over the
@@ -758,7 +805,7 @@ const AssetDetails = ({ asset, closeDetails })=>{
758
805
  return;
759
806
  }
760
807
  trackUsage('didCropFile', {
761
- location: useTracking.MEDIA_LIBRARY_LOCATION,
808
+ location: trackedLocation,
762
809
  duplicatedFile: true
763
810
  });
764
811
  notify({
@@ -800,9 +847,12 @@ const AssetDetails = ({ asset, closeDetails })=>{
800
847
  });
801
848
  return /*#__PURE__*/ jsxRuntime.jsxs(jsxRuntime.Fragment, {
802
849
  children: [
803
- /*#__PURE__*/ jsxRuntime.jsx(strapiAdmin.Blocker, {
850
+ guardNavigation ? /*#__PURE__*/ jsxRuntime.jsx(strapiAdmin.Blocker, {
804
851
  onProceed: resetForm
805
- }),
852
+ }) : hasUnsavedEditsRef ? /*#__PURE__*/ jsxRuntime.jsx(ReportUnsavedEdits, {
853
+ target: hasUnsavedEditsRef,
854
+ hasUnsavedEdits: modified && !isSubmitting
855
+ }) : null,
806
856
  isCropOpen && isImage ? /*#__PURE__*/ jsxRuntime.jsx(AssetCropEditor.AssetCropEditor, {
807
857
  asset: asset,
808
858
  onClose: ()=>setIsCropOpen(false),
@@ -1060,7 +1110,7 @@ const DrawerHeader = ({ asset, closeDetails })=>{
1060
1110
  ]
1061
1111
  });
1062
1112
  };
1063
- const DrawerContent = ({ assetId, closeDetails })=>{
1113
+ const DrawerContent = ({ assetId, requestClose, closeDetails, hasUnsavedEditsRef, guardNavigation, onAssetChange, onAssetDelete, trackedLocation })=>{
1064
1114
  const { formatMessage } = reactIntl.useIntl();
1065
1115
  const { data: asset, isLoading, error } = assets.useGetAssetQuery(assetId, {
1066
1116
  refetchOnMountOrArgChange: false,
@@ -1091,7 +1141,7 @@ const DrawerContent = ({ assetId, closeDetails })=>{
1091
1141
  id: 'global.close',
1092
1142
  defaultMessage: 'Close'
1093
1143
  }),
1094
- onClose: closeDetails,
1144
+ onClose: requestClose,
1095
1145
  children: formatMessage({
1096
1146
  id: translations.getTranslationKey('asset-details.error'),
1097
1147
  defaultMessage: 'Failed to load file details.'
@@ -1103,11 +1153,16 @@ const DrawerContent = ({ assetId, closeDetails })=>{
1103
1153
  children: [
1104
1154
  /*#__PURE__*/ jsxRuntime.jsx(DrawerHeader, {
1105
1155
  asset: asset,
1106
- closeDetails: closeDetails
1156
+ closeDetails: requestClose
1107
1157
  }),
1108
1158
  /*#__PURE__*/ jsxRuntime.jsx(AssetDetails, {
1109
1159
  asset: asset,
1110
- closeDetails: closeDetails
1160
+ closeDetails: closeDetails,
1161
+ hasUnsavedEditsRef: hasUnsavedEditsRef,
1162
+ guardNavigation: guardNavigation,
1163
+ onAssetChange: onAssetChange,
1164
+ onAssetDelete: onAssetDelete,
1165
+ trackedLocation: trackedLocation
1111
1166
  })
1112
1167
  ]
1113
1168
  });
@@ -1140,15 +1195,29 @@ const DrawerContent = ({ assetId, closeDetails })=>{
1140
1195
  // and stop the card's click, so no switch follows and the press dismisses.
1141
1196
  return event.target.closest(constants.ASSET_DETAILS_TRIGGER_SELECTOR) !== null && event.target.closest(constants.ASSET_ITEM_CONTROL_SELECTOR) === null;
1142
1197
  };
1143
- const AssetDetailsDrawer = ()=>{
1198
+ const AssetDetailsDrawer = ({ assetId, onClose, guardNavigation = false, onAssetChange, onAssetDelete, trackedLocation })=>{
1144
1199
  const { formatMessage } = reactIntl.useIntl();
1145
- const { assetId, isVisible, shouldRenderDrawer, onCloseAnimationEnd, closeDetails } = useAssetDetailsParam();
1146
- if (!shouldRenderDrawer || assetId === null) {
1200
+ const { displayAssetId, isVisible, shouldRender, onCloseAnimationEnd } = useDrawerPresence(assetId);
1201
+ const hasUnsavedEditsRef = React__namespace.useRef(false);
1202
+ const [isConfirmingClose, setIsConfirmingClose] = React__namespace.useState(false);
1203
+ // Where focus was when the drawer opened — the card or the field's edit
1204
+ // button — so closing can hand it back instead of dropping it on <body>.
1205
+ const returnFocusRef = React__namespace.useRef(null);
1206
+ const requestClose = React__namespace.useCallback(()=>{
1207
+ if (hasUnsavedEditsRef.current) {
1208
+ setIsConfirmingClose(true);
1209
+ return;
1210
+ }
1211
+ onClose();
1212
+ }, [
1213
+ onClose
1214
+ ]);
1215
+ if (!shouldRender || displayAssetId === null) {
1147
1216
  return null;
1148
1217
  }
1149
1218
  return /*#__PURE__*/ jsxRuntime.jsxs(Drawer.Drawer.Root, {
1150
1219
  isVisible: isVisible,
1151
- onClose: closeDetails,
1220
+ onClose: requestClose,
1152
1221
  children: [
1153
1222
  /*#__PURE__*/ jsxRuntime.jsx("div", {
1154
1223
  children: /*#__PURE__*/ jsxRuntime.jsxs(designSystem.VisuallyHidden, {
@@ -1168,7 +1237,7 @@ const AssetDetailsDrawer = ()=>{
1168
1237
  ]
1169
1238
  })
1170
1239
  }),
1171
- /*#__PURE__*/ jsxRuntime.jsx(Drawer.Drawer.Body, {
1240
+ /*#__PURE__*/ jsxRuntime.jsxs(Drawer.Drawer.Body, {
1172
1241
  animationDirection: "left",
1173
1242
  width: "41.6rem",
1174
1243
  // dvh, not vh: the drawer is anchored to the bottom, so with 100vh on
@@ -1177,24 +1246,71 @@ const AssetDetailsDrawer = ()=>{
1177
1246
  // off-screen. dvh tracks the actual visible height.
1178
1247
  height: "100dvh",
1179
1248
  onAnimationEnd: onCloseAnimationEnd,
1180
- // Opt in to dismiss-on-outside-click; `closeDetails` is the same path
1181
- // as the header's close button, so unsaved edits are still guarded.
1249
+ // Runs before focus moves in, so this is still the element that opened it.
1250
+ onOpenAutoFocus: ()=>{
1251
+ returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
1252
+ },
1253
+ // By now the panel is gone: focus is on <body> unless the user moved it
1254
+ // somewhere — a press on another control dismissed the drawer — and that
1255
+ // choice wins.
1256
+ onCloseAutoFocus: (event)=>{
1257
+ const target = returnFocusRef.current;
1258
+ const active = document.activeElement;
1259
+ if (target?.isConnected && (active === null || active === document.body)) {
1260
+ event.preventDefault();
1261
+ target.focus();
1262
+ }
1263
+ },
1264
+ // Opt in to dismiss-on-outside-click; it goes through `requestClose`,
1265
+ // like the header's close button, so unsaved edits are still guarded.
1182
1266
  onPointerDownOutside: (event)=>{
1183
1267
  if (shouldKeepDrawerOpen(event, isVisible)) {
1184
1268
  event.preventDefault();
1185
1269
  }
1186
1270
  },
1187
- children: /*#__PURE__*/ jsxRuntime.jsx(DrawerContent, {
1188
- assetId: assetId,
1189
- closeDetails: closeDetails
1190
- })
1271
+ children: [
1272
+ /*#__PURE__*/ jsxRuntime.jsx(DrawerContent, {
1273
+ assetId: displayAssetId,
1274
+ requestClose: requestClose,
1275
+ closeDetails: onClose,
1276
+ hasUnsavedEditsRef: hasUnsavedEditsRef,
1277
+ guardNavigation: guardNavigation,
1278
+ onAssetChange: onAssetChange,
1279
+ onAssetDelete: onAssetDelete,
1280
+ trackedLocation: trackedLocation
1281
+ }),
1282
+ /*#__PURE__*/ jsxRuntime.jsx(designSystem.Dialog.Root, {
1283
+ open: isConfirmingClose,
1284
+ onOpenChange: setIsConfirmingClose,
1285
+ children: /*#__PURE__*/ jsxRuntime.jsx(strapiAdmin.ConfirmDialog, {
1286
+ onConfirm: onClose,
1287
+ variant: "danger",
1288
+ children: formatMessage({
1289
+ id: translations.getTranslationKey('asset-details.unsaved.confirm'),
1290
+ defaultMessage: "You have unsaved changes, are you sure you want to close this file's details? Your changes will be lost."
1291
+ })
1292
+ })
1293
+ })
1294
+ ]
1191
1295
  })
1192
1296
  ]
1193
1297
  });
1194
1298
  };
1299
+ /**
1300
+ * The drawer bound to the URL's `?assetId=`, as the Media Library page uses it:
1301
+ * linkable, survives a refresh, and closing is a navigation.
1302
+ */ const AssetDetailsDrawerFromUrl = ()=>{
1303
+ const { assetId, closeDetails } = useAssetDetailsParam();
1304
+ return /*#__PURE__*/ jsxRuntime.jsx(AssetDetailsDrawer, {
1305
+ assetId: assetId,
1306
+ onClose: closeDetails,
1307
+ guardNavigation: true
1308
+ });
1309
+ };
1195
1310
 
1196
1311
  exports.AssetDetails = AssetDetails;
1197
1312
  exports.AssetDetailsDrawer = AssetDetailsDrawer;
1313
+ exports.AssetDetailsDrawerFromUrl = AssetDetailsDrawerFromUrl;
1198
1314
  exports.AssetOperationsContext = AssetOperationsContext;
1199
1315
  exports.DrawerNotifyContext = DrawerNotifyContext;
1200
1316
  exports.getBusyMessage = getBusyMessage;