Răsfoiți Sursa

Market images in API and NEO: cover/icon in forms and on the product … (#5890)

* Market images in API and NEO: cover/icon in forms and on the product page

UNA:
- Base forms send form_type (getApiFormType, 'content' by default) so NEO
  picks the content form layout for module entry forms.
- Single image inputs carry image_type ('picture' | 'cover'): profile
  picture/cover/badge, text modules covers, Market cover/thumb.
- Market API forms get Header Image (cover) and Icon (thumb) fields with
  their current files; uploads made while editing are associated with the
  product, the replaced file is deleted.
- Market entity_attachments in API returns product photos.
- entity_text in API returns images_edit (field, storage, uploader per
  image type) when the viewer may change images on the view page.
- update_image only changes image fields (the field name came from the
  request) and casts the file id to int.
- Market deletes the replaced image in serviceUpdateImage, after a
  successful update only (it used to delete it even when refused).

NEO:
- Forms without their own component use the layout from form_type.
- files field: image_type picks the picker, crop and tile; field names stay
  as a fallback for older UNA versions.
- entity-text: change cover and product icon on the page (images_edit),
  long titles wrap next to the icon.

No database migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Market images: review fixes (uploads, failed saves, display URLs, Sonar)

- NEO files field: single image fields (image_type) upload with m=1. With m=0 UNA
  deletes the profile's other pending uploads of the uploader and content, so
  picking the Market icon dropped a cover or photos uploaded in the same form.
- Market update: images are associated and replaced files deleted only when
  the product is saved; a failed save keeps the old files in use.
- update_image in API returns a transcoded image URL (cover / thumb, gallery as
  a fallback) instead of the original upload; the site keeps its URL.
- Market entryAttachments in API: product files stay out on purpose (downloads
  for buyers, given by entity_downloads after checkAllowedDownload), and are
  dropped before the storage check too.
- BxMarketFormEntry::getCodeAPI is public (Sonar php:S1784).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: romanlesnikov <roman.lesnikov@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Anton 5 zile în urmă
părinte
comite
1dcbc7e1aa

+ 5 - 0
modules/base/general/classes/BxBaseModGeneralConfig.php

@@ -125,6 +125,11 @@ class BxBaseModGeneralConfig extends BxDolModuleConfig
 
         return isset($this->_aObjects[$sType]) ? $this->_aObjects[$sType] : '';
     }
+    
+    public function getApiFormType()
+    {
+        return 'content';
+    }
 
     public function getPrefix($sType = '')
     {

+ 1 - 0
modules/base/general/classes/BxBaseModGeneralFormsEntryHelper.php

@@ -874,6 +874,7 @@ class BxBaseModGeneralFormsEntryHelper extends BxDolProfileForms
                 $aResult = bx_api_get_block('form', $mixedData, [
                     'ext' => [
                         'name' => $sModule, 
+                        'form_type' => $this->_oModule->_oConfig->getApiFormType(),
                         'request' => ['url' => $sRequestUrl, 'immutable' => true]
                     ]
                 ]);

+ 46 - 1
modules/base/general/classes/BxBaseModGeneralModule.php

@@ -1271,6 +1271,12 @@ class BxBaseModGeneralModule extends BxDolModule
         if($this->checkAllowedEdit($aData) !== CHECK_ACTION_RESULT_ALLOWED)
             return false;
 
+        // The field name comes from the request (the service is open in API): image fields only.
+        if(!in_array($sFiledName, $this->_getUpdateImageFields(), true))
+            return false;
+
+        $sValue = (int)$sValue;
+
         //TODO add transcoder settings
         $oImgStorage = BxDolStorage::getObjectInstance($CNF['OBJECT_STORAGE']);
         if(!$oImgStorage)
@@ -1282,7 +1288,46 @@ class BxBaseModGeneralModule extends BxDolModule
 
         $this->onUpdateImage($iContentId, $sFiledName, $sValue);
 
-        return $oImgStorage->getFileUrlById($sValue);
+        return $this->_bIsApi ? $this->_getUpdateImageUrlApi($sFiledName, $sValue, $oImgStorage) : $oImgStorage->getFileUrlById($sValue);
+    }
+
+    /**
+     * API: the changed image as the app shows it, a transcoded (display) size instead of the original upload.
+     */
+    protected function _getUpdateImageUrlApi($sFiledName, $iFileId, $oImgStorage)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aTranscoders = [];
+        if(!empty($CNF['FIELD_COVER']) && $sFiledName == $CNF['FIELD_COVER']) {
+            $aTranscoders = ['OBJECT_IMAGES_TRANSCODER_COVER', 'OBJECT_IMAGES_TRANSCODER_GALLERY'];
+        }
+        else if(!empty($CNF['FIELD_THUMB']) && $sFiledName == $CNF['FIELD_THUMB']) {
+            $aTranscoders = ['OBJECT_IMAGES_TRANSCODER_THUMB', 'OBJECT_IMAGES_TRANSCODER_GALLERY'];
+        }
+
+        foreach($aTranscoders as $sKey) {
+            if(!empty($CNF[$sKey]) && ($oTranscoder = BxDolTranscoderImage::getObjectInstance($CNF[$sKey])) && ($sUrl = $oTranscoder->getFileUrl($iFileId))) {
+                return $sUrl;
+            }
+        }
+
+        return $oImgStorage->getFileUrlById($iFileId);
+    }
+
+    /**
+     * Content fields holding a single image, which update_image may change.
+     */
+    protected function _getUpdateImageFields()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aResult = [];
+        foreach(['FIELD_THUMB', 'FIELD_COVER', 'FIELD_PICTURE', 'FIELD_BADGE'] as $sKey)
+            if(!empty($CNF[$sKey]))
+                $aResult[] = $CNF[$sKey];
+
+        return $aResult;
     }
 
     public function serviceUpdateImagePosition($iContentId, $sFiledName, $sH, $sV)

+ 6 - 1
modules/base/profile/classes/BxBaseModProfileFormEntry.php

@@ -35,6 +35,7 @@ class BxBaseModProfileFormEntry extends BxBaseModGeneralFormEntry
                 'storage_object' => $CNF['OBJECT_STORAGE'],
                 'images_transcoder' => $CNF['OBJECT_IMAGES_TRANSCODER_THUMB'],
                 'uploaders' => $CNF['OBJECT_UPLOADERS_PICTURE'],
+                'image_type' => 'picture',
             );
         }
 
@@ -55,7 +56,8 @@ class BxBaseModProfileFormEntry extends BxBaseModGeneralFormEntry
                 'storage_object' => $CNF['OBJECT_STORAGE_COVER'],
                 'images_transcoder' => $CNF['OBJECT_IMAGES_TRANSCODER_COVER_THUMB'],
                 'uploaders_id' => $sUploadersId,
-                'uploaders' => $aUploaders
+                'uploaders' => $aUploaders,
+                'image_type' => 'cover',
             );
 
             // Lets the app crop a profile cover in the profile aspect ratio.
@@ -67,6 +69,7 @@ class BxBaseModProfileFormEntry extends BxBaseModGeneralFormEntry
                 'storage_object' => $CNF['OBJECT_STORAGE_BADGE'],
                 'images_transcoder' => $CNF['OBJECT_IMAGES_TRANSCODER_BADGE'],
                 'uploaders' => $CNF['OBJECT_UPLOADERS_BADGE'],
+                'image_type' => 'picture',
             ];
         }
 
@@ -108,6 +111,8 @@ class BxBaseModProfileFormEntry extends BxBaseModGeneralFormEntry
             $this->aInputs[$sField]['multiple'] = false;
             $this->aInputs[$sField]['content_id'] = 0;
             $this->aInputs[$sField]['ghost_template'] = '';
+            // Tells the app how to show and crop the image ('picture' | 'cover').
+            $this->aInputs[$sField]['image_type'] = $aParams['image_type'] ?? '';
         }
 
         if(($sField = 'friends_count') && isset($this->aInputs[$sField]) && !$this->_oModule->_oConfig->isFriends())

+ 2 - 0
modules/base/text/classes/BxBaseModTextFormEntry.php

@@ -37,6 +37,8 @@ class BxBaseModTextFormEntry extends BxBaseModGeneralFormEntry
                 $this->aInputs[$CNF[$sKf]]['multiple'] = false;
                 $this->aInputs[$CNF[$sKf]]['content_id'] = 0;
                 $this->aInputs[$CNF[$sKf]]['ghost_template'] = '';
+                // Tells the app how to show and crop the image.
+                $this->aInputs[$CNF[$sKf]]['image_type'] = 'cover';
             }
             else
                 unset($this->aInputs[$CNF[$sKf]]);

+ 36 - 0
modules/base/text/classes/BxBaseModTextTemplate.php

@@ -279,6 +279,9 @@ class BxBaseModTextTemplate extends BxBaseModGeneralTemplate
             if($sImage)
                 $aVars['image'] = bx_api_get_image($aImageParams['storage'], $mixedImage['id']);
 
+            // Lets the app change the images right on the view page, like a profile cover.
+            $aVars['images_edit'] = $this->_getImagesEditApi($aData);
+
             return $aVars;
         }
 
@@ -676,6 +679,39 @@ class BxBaseModTextTemplate extends BxBaseModGeneralTemplate
     {
         return $this->getModule()->getEntryImageData($aData);
     }
+
+    /**
+     * Images the viewer may change on the view page, for API: [image_type => [field, storage, uploader]].
+     * Empty when it's not allowed. The app uploads to the storage and calls update_image with the field.
+     */
+    protected function _getImagesEditApi($aData)
+    {
+        $CNF = &$this->getModule()->_oConfig->CNF;
+
+        if(empty($CNF['FIELD_THUMB']) || empty($CNF['OBJECT_STORAGE']) || empty($CNF['OBJECT_UPLOADERS']))
+            return [];
+
+        // The same rule as for the header image editor on the site.
+        if($this->getModule()->checkAllowedEdit($aData) !== CHECK_ACTION_RESULT_ALLOWED)
+            return [];
+
+        return [
+            'cover' => $this->_getImageEditApi($CNF['FIELD_THUMB']),
+        ];
+    }
+
+    protected function _getImageEditApi($sField)
+    {
+        $CNF = &$this->getModule()->_oConfig->CNF;
+
+        $mixedUploaders = $CNF['OBJECT_UPLOADERS'];
+
+        return [
+            'field' => $sField,
+            'storage' => $CNF['OBJECT_STORAGE'],
+            'uploader' => is_array($mixedUploaders) ? reset($mixedUploaders) : $mixedUploaders,
+        ];
+    }
     
     function mediaExif ($aMediaInfo, $iProfileId = false, $sFuncAuthorDesc = '', $sTemplateName = 'media-exif.html') 
     {

+ 168 - 10
modules/boonex/market/classes/BxMarketFormEntry.php

@@ -16,6 +16,12 @@ class BxMarketFormEntry extends BxBaseModTextFormEntry
 {
     protected $_bPhotoGhostCheckboxes;
 
+    /*
+     * Header Image and Icon fields shown in API forms only, the site sets them on view product page.
+     */
+    protected $_aImageFieldsApi;
+    protected $_aContentInfoApi;
+
     public function __construct($aInfo, $oTemplate = false)
     {
         $this->MODULE = 'bx_market';
@@ -104,6 +110,106 @@ class BxMarketFormEntry extends BxBaseModTextFormEntry
 
         if(isset($this->aInputs[$CNF['FIELD_SUBENTRIES']]) && $this->_oModule->checkAllowedSetSubentries() !== CHECK_ACTION_RESULT_ALLOWED)
             unset($this->aInputs[$CNF['FIELD_SUBENTRIES']]);
+
+        $this->_aImageFieldsApi = [];
+        $this->_aContentInfoApi = [];
+        if($this->_bIsApi && $this->_oModule->checkAllowedSetCover() === CHECK_ACTION_RESULT_ALLOWED)
+            $this->_addImageFieldsApi();
+    }
+
+    protected function _addImageFieldsApi()
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        if(getParam($CNF['PARAM_USE_ICON']) == 'on')
+            $this->_aImageFieldsApi[$CNF['FIELD_THUMB']] = '_bx_market_form_entry_input_pictures_use_as_thumb';
+        $this->_aImageFieldsApi[$CNF['FIELD_COVER']] = '_bx_market_form_entry_input_pictures_use_as_cover';
+
+        // Tells the app how to show and crop the image, field names differ between modules.
+        $aImageTypes = [
+            $CNF['FIELD_THUMB'] => 'picture',
+            $CNF['FIELD_COVER'] => 'cover',
+        ];
+
+        $aInputs = [];
+        foreach($this->_aImageFieldsApi as $sField => $sCaption)
+            $aInputs[$sField] = [
+                'type' => 'files',
+                'name' => $sField,
+                'caption' => _t($sCaption),
+                'image_type' => $aImageTypes[$sField],
+                'storage_object' => $CNF['OBJECT_STORAGE'],
+                'uploaders' => $CNF['OBJECT_UPLOADERS'],
+                'images_transcoder' => $CNF['OBJECT_IMAGES_TRANSCODER_PREVIEW'],
+                'storage_private' => 0,
+                'multiple' => false,
+                'content_id' => 0,
+                'ghost_template' => '',
+            ];
+
+        // Put them before product pictures.
+        $iPos = array_search('pictures', array_keys($this->aInputs));
+        if($iPos === false)
+            $this->aInputs = array_merge($this->aInputs, $aInputs);
+        else
+            $this->aInputs = array_merge(array_slice($this->aInputs, 0, $iPos, true), $aInputs, array_slice($this->aInputs, $iPos, null, true));
+    }
+
+    public function getCodeAPI()
+    {
+        $aResult = parent::getCodeAPI();
+        if(empty($this->_aImageFieldsApi) || empty($this->_aContentInfoApi) || empty($aResult['inputs']))
+            return $aResult;
+
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $oStorage = BxDolStorage::getObjectInstance($CNF['OBJECT_STORAGE']);
+        if(!$oStorage)
+            return $aResult;
+
+        $oTranscoder = BxDolTranscoderImage::getObjectInstance($CNF['OBJECT_IMAGES_TRANSCODER_PREVIEW']);
+
+        foreach($this->_aImageFieldsApi as $sField => $sCaption) {
+            if(!isset($aResult['inputs'][$sField]) || !($iFileId = (int)($this->_aContentInfoApi[$sField] ?? 0)))
+                continue;
+
+            $aFile = $oStorage->getFile($iFileId);
+            if(empty($aFile) || !is_array($aFile))
+                continue;
+
+            $sUrl = $oStorage->getFileUrlById($iFileId);
+            $aResult['inputs'][$sField]['values_src'] = [[
+                'storage_object' => $CNF['OBJECT_STORAGE'],
+                'file_id' => $iFileId,
+                'file_type' => $aFile['mime_type'],
+                'file_name' => $aFile['file_name'],
+                'file_icon' => $oTranscoder ? $oTranscoder->getFileUrl($iFileId) : $sUrl,
+                'file_url' => $sUrl,
+            ]];
+            $aResult['inputs'][$sField]['value'] = (string)$iFileId;
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * Header Image and Icon ids submitted from API form: [field => file id], only for submitted fields.
+     */
+    protected function _getImageFieldsApiValues()
+    {
+        $aResult = [];
+        foreach($this->_aImageFieldsApi as $sField => $sCaption) {
+            $mixedValue = $this->getCleanValue($sField);
+            if($mixedValue === false || $mixedValue === null)
+                continue;
+
+            if(!is_array($mixedValue))
+                $mixedValue = $mixedValue !== '' ? explode(',', $mixedValue) : [];
+
+            $aResult[$sField] = (int)array_pop($mixedValue);
+        }
+
+        return $aResult;
     }
 
     function getCode($bDynamicMode = false)
@@ -133,6 +239,9 @@ class BxMarketFormEntry extends BxBaseModTextFormEntry
                 $this->aInputs[$CNF['FIELD_SUBENTRIES']]['value'] = $oConnection->getConnectedContent((int)$aValues[$CNF['FIELD_ID']]);
         }
 
+        if(!empty($this->_aImageFieldsApi) && !empty($aValues[$CNF['FIELD_ID']]))
+            $this->_aContentInfoApi = $aValues;
+
         return parent::initChecker($aValues, $aSpecificValues);
     }
 
@@ -158,15 +267,23 @@ class BxMarketFormEntry extends BxBaseModTextFormEntry
                 $aValsToAdd[$CNF['FIELD_COVER']] = $iFileCover;
         }
 
+        if(!empty($this->_aImageFieldsApi))
+            $aValsToAdd = array_merge($aValsToAdd, $this->_getImageFieldsApiValues());
+
         $aPackage = bx_process_input(bx_get($CNF['FIELD_PACKAGE']), BX_DATA_INT);
         $aValsToAdd[$CNF['FIELD_PACKAGE']] = 0;
         if(!empty($aPackage) && is_array($aPackage) && ($iFilePackage = array_pop($aPackage)))
             $aValsToAdd[$CNF['FIELD_PACKAGE']] = $iFilePackage;
 
         $iContentId = parent::insert ($aValsToAdd, $isIgnore);
-        if(!empty($iContentId))
+        if(!empty($iContentId)) {
             $this->processFiles($CNF['FIELD_FILE'], $iContentId, true, $CNF['OBJECT_STORAGE_FILES']);
 
+            // Cover is processed by parent.
+            if(isset($this->_aImageFieldsApi[$CNF['FIELD_THUMB']]))
+                $this->processFiles($CNF['FIELD_THUMB'], $iContentId, true);
+        }
+
         return $iContentId;
     }
 
@@ -196,25 +313,66 @@ class BxMarketFormEntry extends BxBaseModTextFormEntry
             }
         }
 
-        $bSetImage = $this->_oModule->checkAllowedSetCover() === CHECK_ACTION_RESULT_ALLOWED; //--- Allow to set both Thumb and Cover.
-        foreach(['FIELD_THUMB', 'FIELD_COVER'] as $sKey)
-            if($bSetImage && ($aImage = bx_get($CNF[$sKey])) !== false) {
-                $aImage = bx_process_input($aImage, BX_DATA_INT);
-
-                $aValsToAdd[$CNF[$sKey]] = 0;
-                if(!empty($aImage) && is_array($aImage) && ($iFileImage = array_pop($aImage)))
-                    $aValsToAdd[$CNF[$sKey]] = $iFileImage;
-            }
+        $aImagesApi = [];
+        if(!empty($this->_aImageFieldsApi)) {
+            $aImagesApi = $this->_getImageFieldsApiValues();
+            $aValsToAdd = array_merge($aValsToAdd, $aImagesApi);
+        }
+        else {
+            $bSetImage = $this->_oModule->checkAllowedSetCover() === CHECK_ACTION_RESULT_ALLOWED; //--- Allow to set both Thumb and Cover.
+            foreach(['FIELD_THUMB', 'FIELD_COVER'] as $sKey)
+                if($bSetImage && ($aImage = bx_get($CNF[$sKey])) !== false) {
+                    $aImage = bx_process_input($aImage, BX_DATA_INT);
+
+                    $aValsToAdd[$CNF[$sKey]] = 0;
+                    if(!empty($aImage) && is_array($aImage) && ($iFileImage = array_pop($aImage)))
+                        $aValsToAdd[$CNF[$sKey]] = $iFileImage;
+                }
+        }
 
         $aPackage = bx_process_input(bx_get($CNF['FIELD_PACKAGE']), BX_DATA_INT);
         $aValsToAdd[$CNF['FIELD_PACKAGE']] = 0;
         if(!empty($aPackage) && is_array($aPackage) && ($iFilePackage = array_pop($aPackage)))
             $aValsToAdd[$CNF['FIELD_PACKAGE']] = $iFilePackage;
 
+        $aContentInfoOld = !empty($aImagesApi) ? $this->_oModule->_oDb->getContentInfoById($iContentId) : [];
+
         $iResult = parent::update ($iContentId, $aValsToAdd, $aTrackTextFieldsChanges);
 
         $this->processFiles($CNF['FIELD_FILE'], $iContentId, false, $CNF['OBJECT_STORAGE_FILES']);
 
+        // Images change only with the saved product: on a failed save the old files stay in use.
+        if($iResult !== false && !empty($aImagesApi)) {
+            /*
+             * API image fields have no content_id, so a file uploaded while editing is a ghost
+             * with content_id = 0: associate it (only submitted ids are taken), otherwise
+             * the ghost gets cleaned up and the field points to a deleted file.
+             */
+            foreach($aImagesApi as $sField => $iFileId)
+                if($iFileId)
+                    $this->processFiles($sField, $iContentId, true);
+
+            // The same as BxMarketModule::actionUpdateImage does: drop the replaced file and the cover position.
+            $oStorage = BxDolStorage::getObjectInstance($CNF['OBJECT_STORAGE']);
+            foreach($aImagesApi as $sField => $iFileId) {
+                $iFileIdOld = (int)($aContentInfoOld[$sField] ?? 0);
+                if($iFileIdOld == $iFileId)
+                    continue;
+
+                if($sField == $CNF['FIELD_COVER'])
+                    $this->_oModule->serviceUpdateImagePosition($iContentId, $sField, '', '');
+
+                // Keep the file if it's still used as the other image.
+                $bUsed = false;
+                foreach(array_keys($this->_aImageFieldsApi) as $sFieldOther)
+                    if($sFieldOther != $sField && (int)($aImagesApi[$sFieldOther] ?? ($aContentInfoOld[$sFieldOther] ?? 0)) == $iFileIdOld)
+                        $bUsed = true;
+
+                if($iFileIdOld && !$bUsed && $oStorage)
+                    $oStorage->deleteFile($iFileIdOld);
+            }
+        }
+
         return $iResult;
     }
 

+ 19 - 8
modules/boonex/market/classes/BxMarketModule.php

@@ -36,22 +36,33 @@ class BxMarketModule extends BxBaseModTextModule
         ));
     }
 
-    public function actionUpdateImage($sFiledName, $iContentId, $sValue)
+    /**
+     * Drops the replaced image file once the field is updated. Works for both
+     * the site (actionUpdateImage) and API (update_image).
+     */
+    public function serviceUpdateImage($sFiledName, $iContentId, $sValue)
     {
         $CNF = &$this->_oConfig->CNF;
 
         $aData = $this->_oDb->getContentInfoById($iContentId);
         if(empty($aData) || !is_array($aData))
-            return;
+            return false;
 
-        $mixedResult = parent::actionUpdateImage($sFiledName, $iContentId, $sValue);
+        $mixedResult = parent::serviceUpdateImage($sFiledName, $iContentId, $sValue);
         if($mixedResult === false)
-            return;
-        
-        if(!empty($aData[$sFiledName]) && ($oStorage = BxDolStorage::getObjectInstance($CNF['OBJECT_STORAGE'])) !== false)
-            $oStorage->deleteFile($aData[$sFiledName]);
+            return false;
+
+        // Keep the file if it's still used as the other image (Icon and Header Image may share one).
+        $iFileIdOld = (int)($aData[$sFiledName] ?? 0);
+        $bUsed = false;
+        foreach([$CNF['FIELD_THUMB'], $CNF['FIELD_COVER']] as $sField)
+            if($sField != $sFiledName && (int)$aData[$sField] == $iFileIdOld)
+                $bUsed = true;
 
-        echo $mixedResult;
+        if($iFileIdOld && $iFileIdOld != (int)$sValue && !$bUsed && ($oStorage = BxDolStorage::getObjectInstance($CNF['OBJECT_STORAGE'])) !== false)
+            $oStorage->deleteFile($iFileIdOld);
+
+        return $mixedResult;
     }
 
     public function actionPerform()

+ 65 - 0
modules/boonex/market/classes/BxMarketTemplate.php

@@ -520,6 +520,25 @@ class BxMarketTemplate extends BxBaseModTextTemplate
     {
         return $this->getModule()->getEntryImageData($aData, 'FIELD_COVER');
     }
+
+    protected function _getImagesEditApi($aData)
+    {
+        $oModule = $this->getModule();
+        $CNF = &$oModule->_oConfig->CNF;
+
+        // The same rule as for Header Image and Icon uploaders on the site's view product page.
+        if($oModule->checkAllowedEdit($aData) !== CHECK_ACTION_RESULT_ALLOWED || $oModule->checkAllowedSetCover() !== CHECK_ACTION_RESULT_ALLOWED)
+            return [];
+
+        $aResult = [
+            'cover' => $this->_getImageEditApi($CNF['FIELD_COVER']),
+        ];
+
+        if(getParam($CNF['PARAM_USE_ICON']) == 'on')
+            $aResult['picture'] = $this->_getImageEditApi($CNF['FIELD_THUMB']);
+
+        return $aResult;
+    }
     
     function _prepareImage($aData, $sUniqId, $sUploader, $sStorage, $sField, $bAllowTweak, $bAllowMultiple = false)
     {
@@ -530,6 +549,52 @@ class BxMarketTemplate extends BxBaseModTextTemplate
 
         return parent::_prepareImage($aData, $sUniqId, $sUploader, $sStorage, $sField, $bAllowTweak, $bAllowMultiple);
     }
+    
+    public function entryAttachments ($aData, $aParams = [])
+    {
+        $mixedResult = parent::entryAttachments($aData, $aParams);
+        if(!$this->_bIsApi || !is_array($mixedResult) || !isset($mixedResult[0]['data']))
+            return $mixedResult;
+
+        $CNF = &$this->getModule()->_oConfig->CNF;
+
+        /*
+         * The product page shows photos here. Product files (OBJECT_STORAGE_FILES, what the parent returns)
+         * stay out on purpose: they are downloads for buyers, the app gets them from entity_downloads
+         * (market_attachments) after checkAllowedDownload, as the site does.
+         */
+        $mixedResult[0]['data'] = [];
+
+        $oStorage = BxDolStorage::getObjectInstance($CNF['OBJECT_STORAGE']);
+        if(!$oStorage) {
+            return $mixedResult;
+        }
+
+        $aPhotos = $this->_oDb->getPhoto(['type' => 'content_id', 'content_id' => $aData[$CNF['FIELD_ID']], 'except' => [$aData[$CNF['FIELD_THUMB']], $aData[$CNF['FIELD_COVER']]]]);
+        if(empty($aPhotos) || !is_array($aPhotos))
+            return $mixedResult;
+
+        $aItems = [];
+        foreach($aPhotos as $aPhoto) {
+            $aFile = $oStorage->getFile($aPhoto['file_id']);
+            if(empty($aFile))
+                continue;
+
+            $aItems[] = [
+                'file_name' => $aFile['file_name'],
+                'mime_type' => $aFile['mime_type'],
+                'ext' => $aFile['ext'],
+                'type' => 'image',
+                'size' => $aFile['size'],
+                'url' => $oStorage->getFileUrlById($aPhoto['file_id']),
+                'data' => bx_api_get_image($CNF['OBJECT_STORAGE'], $aPhoto['file_id']),
+            ];
+        }
+
+        $mixedResult[0]['data'] = $aItems;
+
+        return $mixedResult;
+    }
 }
 
 /** @} */

+ 114 - 12
neo/packages/app/components/elements/entity-text.js

@@ -1,7 +1,7 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { Platform } from 'react-native';
 import { useForm, FormProvider } from 'react-hook-form';
-import { View, Pressable } from 'app/design/view';
+import { View, Row, Pressable } from 'app/design/view';
 import Image, { POST_ENTRY_COVER_SIZES, POST_ENTRY_COVER_WIDTH_CAP } from 'app/ui/atoms/image';
 import { getCoverAspectClass } from 'app/lib/image/upload-settings';
 import Html from 'app/ui/atoms/html';
@@ -20,6 +20,9 @@ import InlineEmbed from 'app/ui/molecules/content/inline-embed'
 import InlineTable from 'app/ui/molecules/content/inline-table'
 import { BlockWrapper } from 'app/components/block-wrapper'
 import RftText from 'app/components/form-fields/editor-rft-text'
+import { NeoButton } from 'app/design/controls'
+import Loading from 'app/ui/atoms/loading'
+import { useProfileImageUpload } from 'app/lib/image/use-profile-image-upload'
 
 const isWeb = Platform.OS === 'web'
 
@@ -244,6 +247,108 @@ const getImagesData = (data) => {
     return att;
 };
 
+/**
+ * Upload state for an entry image UNA lets the viewer change on the page (`images_edit[kind]`:
+ * field, storage, uploader). A changed image has no UNA sizes yet, so it comes back as `{ src }`.
+ */
+function useEntryImageEdit({ kind, edit, module, contentId, image }) {
+    const { imageUrl, isUploading, pick, modal } = useProfileImageUpload({
+        kind,
+        profileId: contentId,
+        moduleName: module || '',
+        storage: edit?.storage || '',
+        uploader: edit?.uploader,
+        field: edit?.field,
+        initialUrl: image?.src || '',
+    })
+    const shown = imageUrl && imageUrl !== image?.src ? { src: imageUrl } : (image || null)
+    return { image: shown, isUploading, pick, modal }
+}
+
+function EntryCover({ data, module, edit }) {
+    const { image, isUploading, pick, modal } = useEntryImageEdit({
+        kind: 'cover', edit, module, contentId: data?.id, image: data?.image,
+    })
+    if (!image?.src && !edit) return null
+
+    return (
+        <>
+            <View className={`w-full ${image?.src ? getCoverAspectClass(module) : 'h-24 bg-accent/50'} mb-4 sm:rounded-xl overflow-hidden`}>
+                {image?.src ? (
+                    <Image
+                        {...image}
+                        alt={data.title}
+                        className="u-cover"
+                        view="cover"
+                        sizes={POST_ENTRY_COVER_SIZES}
+                        optimizedWidthCap={POST_ENTRY_COVER_WIDTH_CAP}
+                    />
+                ) : null}
+                {isUploading ? (
+                    <View className="absolute inset-0 opacity-60 bg-card justify-center items-center">
+                        <Loading />
+                    </View>
+                ) : null}
+                {edit ? (
+                    <View className="absolute top-3 right-3">
+                        <NeoButton
+                            image="Camera"
+                            style="glass"
+                            controlSize="regular"
+                            borderShape="circle"
+                            disabled={isUploading}
+                            onPress={pick}
+                        />
+                    </View>
+                ) : null}
+            </View>
+            {modal}
+        </>
+    )
+}
+
+function EntryIcon({ data, module, edit }) {
+    const { image, isUploading, pick, modal } = useEntryImageEdit({
+        kind: 'picture', edit, module, contentId: data?.id, image: data?.icon,
+    })
+    if (!image?.src && !edit) return null
+
+    return (
+        <>
+            <View className="relative mr-3 shrink-0">
+                <View className="w-12 h-12 rounded-xl overflow-hidden bg-accent/50">
+                    {image?.src ? (
+                        <Image
+                            {...image}
+                            alt={data.title}
+                            className="u-cover"
+                            view="cover"
+                        />
+                    ) : null}
+                    {isUploading ? (
+                        <View className="absolute inset-0 bg-card/80 items-center justify-center">
+                            <Loading />
+                        </View>
+                    ) : null}
+                </View>
+                {edit ? (
+                    <View className="absolute -bottom-1 -right-1">
+                        <NeoButton
+                            image="Camera"
+                            style="glass"
+                            controlSize="mini"
+                            borderShape="circle"
+                            disabled={isUploading}
+                            onPress={pick}
+                        />
+                    </View>
+                ) : null}
+            </View>
+            {modal}
+        </>
+    )
+}
+
 /** Post body with editor videos, embeds (standalone link paragraphs) and tables rendered inline. */
 function HtmlWithMedia({ html }) {
     // Split the raw HTML: clearLinks would turn UNA hrefs into relative ones.
@@ -279,19 +384,14 @@ function Default({ data, showPad, sidebar, block, blockWrapperProps, editable, a
                 {(videoId) && <View className='w-full aspect-video rounded-xl overflow-hidden mb-3'>
                     <Youtube videoId={videoId} size={3} />
                 </View>}
-                {(!!data.image && !data.video) && (
-                    <View className={`w-full ${getCoverAspectClass(block?.module)} mb-4 sm:rounded-xl overflow-hidden`}>
-                        <Image
-                            {...data.image}
-                            alt={data.title}
-                            className="u-cover"
-                            view="cover"
-                            sizes={POST_ENTRY_COVER_SIZES}
-                            optimizedWidthCap={POST_ENTRY_COVER_WIDTH_CAP}
-                        />
-                    </View>
+                {!data.video && (
+                    <EntryCover data={data} module={block?.module} edit={data.images_edit?.cover} />
                 )}
                 <View className={`mx-auto w-full ${(showPad == false || sidebar ? '' : ' ')}`}>
+                <Row className="w-full items-center">
+                <EntryIcon data={data} module={block?.module} edit={data.images_edit?.picture} />{/* Market product icon */}
+                    {/* min-w-0: a long title wraps next to the icon instead of pushing out of the block. */}
+                    <View className="flex-1 min-w-0">
                     <EditableTitleField
                         editable={editable}
                         requestUrl={requestUrl}
@@ -304,6 +404,8 @@ function Default({ data, showPad, sidebar, block, blockWrapperProps, editable, a
                                 : <H1C>{title}</H1C>
                         )}
                     </EditableTitleField>
+                    </View>
+                    </Row>
                     {afterTitle}
                     <EditableTextField
                         editable={editable}

+ 30 - 12
neo/packages/app/components/form-fields/files.js

@@ -41,6 +41,8 @@ function resolvePickerSource(source, fallback = 'library') {
     return fallback;
 }
 
+// 'picture', 'covers', 'avatar' and 'badge' are here for older UNA versions; newer ones mark
+// single image fields with `image_type` (see isImageField).
 const IMAGE_FIELD_NAMES = new Set(['photo', 'cmt_image', 'pictures', 'picture', 'covers', 'avatar', 'badge']);
 const VIDEO_FIELD_NAMES = new Set(['video', 'videos']);
 const FILE_FIELD_NAMES = new Set(['file', 'files']);
@@ -49,7 +51,13 @@ const STORAGE_DRIVEN_FIELD_NAMES = new Set(['cmt_image']);
 const IMAGE_EXTS = ['jpg', 'jpeg', 'jpe', 'gif', 'png', 'svg', 'webp', 'heic', 'heif'];
 const VIDEO_EXTS = ['mp4', 'mov', 'm4v', 'avi', 'webm', '3gp'];
 
-function shouldUseMediaPicker(fieldName, extDeny, extAllow) {
+// UNA's `image_type` ('picture' | 'cover') marks a single image field whatever its name.
+function isImageField(fieldName, imageType) {
+    return !!imageType || IMAGE_FIELD_NAMES.has(fieldName);
+}
+
+function shouldUseMediaPicker(fieldName, extDeny, extAllow, imageType) {
+    if (imageType) return true;
     if (FILE_FIELD_NAMES.has(fieldName)) return false;
     if (IMAGE_FIELD_NAMES.has(fieldName) || VIDEO_FIELD_NAMES.has(fieldName)) return true;
     return isMediaField(extDeny, extAllow);
@@ -76,8 +84,8 @@ function allowListHasAny(extAllow, exts) {
  * Camera + videos requires NSMicrophoneUsageDescription on iOS; image-only
  * fields must not request video so photo capture works without mic permission.
  */
-function resolvePickerMediaTypes(fieldName, extAllow, source) {
-    if (IMAGE_FIELD_NAMES.has(fieldName)) {
+function resolvePickerMediaTypes(fieldName, extAllow, source, imageType) {
+    if (isImageField(fieldName, imageType)) {
         return ['images'];
     }
     if (VIDEO_FIELD_NAMES.has(fieldName)) {
@@ -193,9 +201,13 @@ export default function (props) {
         }
     }, [persistKey, props?.values_src]);
 
+    // `m=0` makes UNA delete the profile's other pending uploads of this uploader and content.
+    // Single image fields (`image_type`) can share the uploader with other fields of the form
+    // (Market cover, icon and photos), so they upload as `m=1` and keep the field's last file.
+    const uploadMultiple = bMultiple || !!props.image_type;
     const url = useMemo(() => {
-        return '/api.php?r=system/get_data_api/TemplUploaderServices/&params[]=&obfuscate_faces=' + obfuscateFaces + '&uo=' + (props.uploaders?.[0] ?? '') + '&so=' + props.storage_object + '&uid=' + genRnd(8) + '&img_trans=' + props.images_transcoder + '&m=' + (bMultiple ? 1 : 0) + '&c=' + props.content_id + '&p=' + (props.privacy ? 1 : 0);
-    }, [obfuscateFaces, props.uploaders, props.storage_object, props.images_transcoder, props.content_id, props.privacy, bMultiple]);
+        return '/api.php?r=system/get_data_api/TemplUploaderServices/&params[]=&obfuscate_faces=' + obfuscateFaces + '&uo=' + (props.uploaders?.[0] ?? '') + '&so=' + props.storage_object + '&uid=' + genRnd(8) + '&img_trans=' + props.images_transcoder + '&m=' + (uploadMultiple ? 1 : 0) + '&c=' + props.content_id + '&p=' + (props.privacy ? 1 : 0);
+    }, [obfuscateFaces, props.uploaders, props.storage_object, props.images_transcoder, props.content_id, props.privacy, uploadMultiple]);
 
     const getImageMergeKey = (img) => {
         // Preload items use hash; server ghosts often have no hash — use stable ids.
@@ -472,7 +484,7 @@ export default function (props) {
 
     const finishPickedAssets = useCallback(async (assets) => {
         if (!assets?.length) return;
-        const kind = getImageCropKind(name);
+        const kind = getImageCropKind(name, props.image_type);
         const asset = assets[0];
 
         if (kind && !shouldSkipImageCrop(formName) && asset && !asset.cropped && !isSvgAsset(asset)) {
@@ -492,7 +504,7 @@ export default function (props) {
 
         const k = await uploadImages(assets);
         setImageSourceN(k, bMultiple);
-    }, [name, bMultiple, formName]);
+    }, [name, props.image_type, bMultiple, formName]);
 
     const handleCropCancel = useCallback(() => {
         setCropSession(null);
@@ -544,7 +556,7 @@ export default function (props) {
         if (bIsMedia) {
             const mediaTypes = mediaTypesOverride?.length
                 ? mediaTypesOverride
-                : resolvePickerMediaTypes(name, extAllow, type);
+                : resolvePickerMediaTypes(name, extAllow, type, props.image_type);
 
             let result = null;
 
@@ -607,14 +619,14 @@ export default function (props) {
                 console.error('Error picking document:', err);
             }
         }
-    }, [name, props.ext_allow, props.ext_deny, bMultiple, hasPermissionCamera, hasPermissionLibrary, requestPermissionCamera, requestPermissionLibrary, finishPickedAssets]);
+    }, [name, props.ext_allow, props.ext_deny, props.image_type, bMultiple, hasPermissionCamera, hasPermissionLibrary, requestPermissionCamera, requestPermissionLibrary, finishPickedAssets]);
 
     const selectImage = useCallback(async (source, mediaTypesOverride) => {
         try {
             const extAllow = props.ext_allow ?? '';
             const extDeny = props.ext_deny ?? '';
             const type = resolvePickerSource(source, props.source ?? 'library');
-            let bIsMedia = shouldUseMediaPicker(name, extDeny, extAllow);
+            let bIsMedia = shouldUseMediaPicker(name, extDeny, extAllow, props.image_type);
             // Camera always shoots photos; the library button follows the storage rules.
             if (STORAGE_DRIVEN_FIELD_NAMES.has(name) && type !== 'camera' && !mediaTypesOverride?.length) {
                 const kind = getStoragePickerKind(extAllow);
@@ -657,7 +669,7 @@ export default function (props) {
             console.error('[files] selectImage failed:', err);
             showPickerError(err);
         }
-    }, [name, props.ext_deny, props.ext_allow, props.source, hasPermissionCamera, hasPermissionLibrary, requestPermissionCamera, requestPermissionLibrary, selectImage1]);
+    }, [name, props.ext_deny, props.ext_allow, props.source, props.image_type, hasPermissionCamera, hasPermissionLibrary, requestPermissionCamera, requestPermissionLibrary, selectImage1]);
 
     useEffect(() => {
         const subscription = emitter.addListener(EVENTS.fieldFiles(name), (data) => {
@@ -896,11 +908,16 @@ function ActionButton({ imagesList, props, selectImage, handleDelete, bMultiple,
         }
         let w = 'web:w-full ' + getCoverAspectClass(props.form_name, !!props.is_profile);
 
+        // Round avatar / badge tiles stay keyed by name: `image_type` 'picture' is also a square
+        // content picture (Market product icon), so it can't tell them apart.
         const isPictureField = props.name == 'picture' || props.name == 'avatar' || props.name == 'badge';
         if (props.name == 'picture' || props.name == 'avatar')
             w = 'w-48 h-48 overflow-hidden rounded-full';
         if (props.name == 'badge')
             w = 'w-18 h-18 overflow-hidden rounded-full';
+        // A content picture (Market product icon): square tile, rounded below like other content images.
+        if (!isPictureField && props.image_type == 'picture')
+            w = 'w-48 h-48';
 
         if (!props.viewClasses) {
             w += isPictureField
@@ -952,7 +969,8 @@ function ActionButton({ imagesList, props, selectImage, handleDelete, bMultiple,
 
 function GhostsList(imagesList, bMultiple, handleDelete, props) {
     const visible = (imagesList || []).filter((img) => !img?.inlinePaste)
-    if (!visible.length || props.name === 'cover' || props.name === 'picture') {
+    // Single image fields show their own tile; the names are for older UNA without `image_type`.
+    if (!visible.length || props.image_type || props.name === 'cover' || props.name === 'picture') {
         return null;
     }
 

+ 8 - 6
neo/packages/app/components/form.js

@@ -40,13 +40,14 @@ function FormProviders({ instanceId, methods, children }) {
     );
 }
 
-function getFormType(name) {
-    return components['form'][String(name)]
+// A form mapped by name wins; otherwise UNA's `form_type` (module entry forms) picks the layout.
+function getFormType(name, formType) {
+    return components['form'][String(name)] ?? (formType ? components['form'][String(formType)] : undefined)
 }
 
-function getFormFieldList(name, inputs, handleSubmit, isInitial = false, lastChangedField, saveOnChanges, formProps) {
+function getFormFieldList(name, inputs, handleSubmit, isInitial = false, lastChangedField, saveOnChanges, formProps, formType) {
 
-    const ElementForm = getFormType(name);
+    const ElementForm = getFormType(name, formType);
 
     if ('undefined' !== typeof ElementForm && isInitial) {
         return;
@@ -97,6 +98,7 @@ export default function Form({
     exProps,
     request,
     url,
+    form_type: formType,
 }) {
     const { t } = useTranslation();
     const router = useRouter();
@@ -589,7 +591,7 @@ export default function Form({
         return result;
     }, [processedInputs, isAutoChange]);
 
-    let inputs = getFormFieldList(name, filteredInputs, _handleSubmit, true, null, saveOnChanges, formProps);
+    let inputs = getFormFieldList(name, filteredInputs, _handleSubmit, true, null, saveOnChanges, formProps, formType);
 
     if (inputs?.length) {
         inputs = inputs.filter(
@@ -616,7 +618,7 @@ export default function Form({
             },
         }));
     }
-    const ElementForm = getFormType(name)
+    const ElementForm = getFormType(name, formType)
 
 
     const defaultFormValues = Object.keys(allFields).reduce((result, key) => {

+ 8 - 1
neo/packages/app/components/forms/_map.js

@@ -17,12 +17,19 @@ export const componentsMapDefault = {
     feed: FormFeed,
     feed_edit: FormFeed,
     bx_posts: FormPost,
+    bx_forum: FormPost,
+
     bx_ads: FormContent,
+    bx_polls: FormContent,
     bx_albums: FormContent,
+    bx_market: FormContent,
     bx_tasks: FormContent,
     bx_tasks_entry_add: FormContent,
-    bx_forum: FormPost,
+    
     bx_messenger: Messenger,
     bx_payment: FormBxPayment,
     bx_payment_form_processed_add: FormBxPayment,
+
+    // Layout chosen by UNA's `form_type` when the form has no entry of its own above.
+    content: FormContent,
 };

+ 1 - 1
neo/packages/app/design/typography.tsx

@@ -152,7 +152,7 @@ export const H1 = ({ children, className, fontFamily = '', isfirst, islast, ...r
     const spacing = getSpacing('mt-2', 'mb-6', isfirst, islast)
 
     return (
-        <HeadingComponent {...noScale} className={`pt-2 text-4xl xl:text-5xl  tracking-tight leading-tight font-bold text-foreground text-balance ${spacing} ${className || ''} ${fontFamily || 'font-title'}`} {...(isWeb ? sanitizeWebTextProps(rest) : rest)}>
+        <HeadingComponent {...noScale} className={`pt-2 text-4xl xl:text-5xl  tracking-tight leading-tight font-bold text-foreground ${spacing} ${className || ''} ${fontFamily || 'font-title'}`} {...(isWeb ? sanitizeWebTextProps(rest) : rest)}>
             {decodeTextNode(children)}
         </HeadingComponent>
     )

+ 7 - 1
neo/packages/app/lib/image/crop.ts

@@ -14,10 +14,16 @@ export const PICTURE_CROP_OUTPUT = 400
 export const COVER_CROP_MAX_WIDTH = 2000
 export const CROP_MAX_ZOOM = 4
 
+// For older UNA versions that don't send `image_type` on files inputs: guess the kind by field name.
 const PICTURE_FIELDS = new Set(['picture', 'avatar', 'badge'])
 const COVER_FIELDS = new Set(['covers', 'cover'])
 
-export function getImageCropKind(fieldName: string) {
+/**
+ * UNA's `image_type` on a files input ('picture' | 'cover') wins: field names mean
+ * different things in different modules (Market `thumb` is the icon, a post's is the cover).
+ */
+export function getImageCropKind(fieldName: string, imageType?: string) {
+    if (imageType === 'picture' || imageType === 'cover') return imageType
     if (PICTURE_FIELDS.has(fieldName)) return 'picture'
     if (COVER_FIELDS.has(fieldName)) return 'cover'
     return null

+ 16 - 8
neo/packages/app/lib/image/use-profile-image-upload.tsx

@@ -16,7 +16,14 @@ export function useProfileImageUpload({
     storage,
     initialUrl,
     isProfile = false,
-}: { kind: any; profileId: any; moduleName: any; storage: any; initialUrl: string; isProfile?: boolean }) {
+    uploader,
+    field,
+}: {
+    kind: any; profileId: any; moduleName: any; storage: any; initialUrl: string; isProfile?: boolean;
+    // Content entries (UNA `images_edit`): their own uploader and the field update_image sets.
+    // Profiles leave them out: `<module>_cover_crop` / `_picture_crop` and the kind as the field.
+    uploader?: string; field?: string;
+}) {
     const [localImageUrl, setLocalImageUrl] = useState<string | undefined>(undefined)
     const [isUploading, setIsUploading] = useState(false)
     const [cropSession, setCropSession] = useState<any>(null)
@@ -38,26 +45,27 @@ export function useProfileImageUpload({
                 '/api.php?r=' +
                 moduleName +
                 '/update_image/&params[]=' +
-                (uploadInfo.extraVar.kind || kind) +
+                (field || uploadInfo.extraVar.kind || kind) +
                 '&params[]=' +
                 profileId +
                 '&params[]=' +
                 uploadInfo.result.data.id
             const sResponse = await fetcher(sRequest)
-            setLocalImageUrl(sResponse.data)
+            // UNA returns false when it refuses: show the previous image again.
+            setLocalImageUrl(sResponse?.data || undefined)
         } finally {
             setIsUploading(false)
         }
-    }, [kind, moduleName, profileId])
+    }, [field, kind, moduleName, profileId])
 
     const handleCropConfirm = useCallback(async ({ uri }: { uri: string }) => {
         const uploadKind = cropSession?.kind || kind
         setCropSession(null)
-        const uploader =
-            moduleName + (uploadKind === 'cover' ? '_cover_crop' : '_picture_crop')
+        const uploaderObject =
+            uploader || moduleName + (uploadKind === 'cover' ? '_cover_crop' : '_picture_crop')
         const url =
             '/api.php?r=system/get_data_api/TemplUploaderServices/&params[]=&uo=' +
-            uploader +
+            uploaderObject +
             '&so=' +
             storage +
             '&uid=' +
@@ -74,7 +82,7 @@ export function useProfileImageUpload({
             handleInsertImageFinish,
             { hash: md5(uri), kind: uploadKind },
         )
-    }, [cropSession, kind, moduleName, storage, profileId, handleInsertImageFinish])
+    }, [cropSession, kind, moduleName, storage, profileId, uploader, handleInsertImageFinish])
 
     // Mounted only while a crop session is open so the lazy chunk is not fetched upfront.
     const modal = cropSession ? (