diff --git a/assets/components/minishop3/css/mgr/main.css b/assets/components/minishop3/css/mgr/main.css
index 8ce55bfa..868e0d9b 100644
--- a/assets/components/minishop3/css/mgr/main.css
+++ b/assets/components/minishop3/css/mgr/main.css
@@ -457,73 +457,6 @@ a.x-menu-item .x-menu-item-text .icon {
height: auto;
}
-/* Gallery */
-#ms3-gallery-page.drag-over:after {
- content: "";
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
- position: absolute;
- display: block;
- opacity: 0.1;
- background: forestgreen;
- border: 5px solid darkgreen;
-}
-
-/* Images list */
-.ms3-gallery-images {
- float: left;
-}
-
-#ms3-gallery-images-view {
- min-height: 150px;
-}
-
-.ms3-gallery-thumb-wrap:hover .modx-browser-thumb {
- border: 1px solid #aaa;
-}
-
-.ms3-gallery-thumb {
- width: 120px;
- height: 90px;
-}
-
-.ms3-gallery-thumb img {
- max-width: 120px;
- max-height: 90px;
- display: block;
- margin: auto;
-}
-
-.ms3-gallery-window-thumb {
- border: 1px solid #e4e4e4;
- border-radius: 2px;
- background: #fdfdfd;
-}
-
-/* Gallery image window */
-.ms3-gallery-cba .x-form-cb-label {
- margin-top: 6px;
-}
-
-.ms3-gallery-window-link {
- width: 100%;
- display: block;
- text-align: center;
-}
-
-.ms3-gallery-window-details {
- width: 100%;
- font-size: 12px;
-}
-
-.ms3-gallery-window-details th {
- text-align: right;
- padding-right: 5px;
- width: 50%;
-}
-
/* Settings */
.x-grid3-col-color {
padding-top: 10px;
diff --git a/assets/components/minishop3/js/mgr/product/gallery/gallery.panel.js b/assets/components/minishop3/js/mgr/product/gallery/gallery.panel.js
deleted file mode 100644
index 7f5e0275..00000000
--- a/assets/components/minishop3/js/mgr/product/gallery/gallery.panel.js
+++ /dev/null
@@ -1,142 +0,0 @@
-ms3.panel.Gallery = function (config) {
- config = config || {};
-
- Ext.apply(config, {
- border: false,
- id: 'ms3-gallery-page',
- baseCls: 'x-panel',
- items: [{
- border: false,
- style: {padding: '10px 5px'},
- xtype: 'ms3-gallery-page-toolbar',
- id: 'ms3-gallery-page-toolbar',
- record: config.record,
- }, {
- border: false,
- style: {padding: '10px 5px'},
- html: '
'
- }, {
- border: false,
- style: {padding: '5px'},
- layout: 'anchor',
- items: [{
- border: false,
- xtype: 'ms3-gallery-images-panel',
- id: 'ms3-gallery-images-panel',
- cls: 'modx-pb-view-ct',
- product_id: config.record.id,
- pageSize: config.pageSize
- }]
- }]
- });
- ms3.panel.Gallery.superclass.constructor.call(this, config);
-
- this.on('afterrender', function () {
- const gallery = this;
- window.setTimeout(function () {
- gallery.initialize();
- }, 100);
- });
-};
-Ext.extend(ms3.panel.Gallery, MODx.Panel, {
- vueUploaderInstance: null,
-
- initialize: function () {
- if (this.initialized) {
- return;
- }
- this._initUploader();
- this.initialized = true;
- },
-
- _initUploader: function () {
- if (typeof window.MS3_initGalleryUploader !== 'function') {
- console.error('[MS3 Gallery] Vue uploader not loaded. Make sure gallery-uploader.min.js is included.');
- MODx.msg.alert(_('error'), 'Gallery uploader not loaded. Please refresh the page.');
- return;
- }
-
- const allowedTypes = ms3.config.media_source.allowedFileTypes || MODx.config.upload_images || 'jpg,jpeg,png,gif,webp';
- const allowedFileTypes = allowedTypes.split(',').map(ext => {
- const mimeTypes = {
- 'jpg': 'image/jpeg',
- 'jpeg': 'image/jpeg',
- 'png': 'image/png',
- 'gif': 'image/gif',
- 'webp': 'image/webp',
- 'avif': 'image/avif',
- 'heic': 'image/heic'
- };
- return mimeTypes[ext.trim()] || 'image/' + ext.trim();
- });
-
- this.vueUploaderInstance = window.MS3_initGalleryUploader({
- containerId: 'ms3-gallery-uploader',
- productId: this.record.id,
- sourceId: this.record.source,
- connectorUrl: ms3.config.connector_url,
- maxFileSize: ms3.config.media_source.maxUploadSize || MODx.config.upload_maxsize || 10485760,
- maxWidth: ms3.config.media_source.maxUploadWidth || 1920,
- maxHeight: ms3.config.media_source.maxUploadHeight || 1080,
- allowedFileTypes: allowedFileTypes,
- onUploadSuccess: this.onUploadSuccess.bind(this),
- onUploadError: this.onUploadError.bind(this),
- onUploadComplete: this.onUploadComplete.bind(this)
- });
-
- if (!this.vueUploaderInstance) {
- console.error('[MS3 Gallery] Failed to initialize Vue uploader');
- }
- },
-
- onUploadSuccess: function (data) {
- const file = data.file;
- const response = data.response;
- console.log('[MS3 Gallery] Upload success:', file.name, response);
- },
-
- onUploadError: function (data) {
- const file = data.file;
- const error = data.error;
- const fileName = file ? file.name : 'File';
- const errorMsg = error.message || 'Upload failed';
- console.error('[MS3 Gallery] Upload error:', fileName, error);
- MODx.msg.alert(_('error'), fileName + ': ' + errorMsg);
- },
-
- onUploadComplete: function (result) {
- console.log('[MS3 Gallery] Upload complete:', result);
-
- const panel = Ext.getCmp('ms3-gallery-images-panel');
- if (panel) {
- panel.view.getStore().reload();
-
- MODx.Ajax.request({
- url: ms3.config.connector_url,
- params: {
- action: 'MiniShop3\\Processors\\Product\\Get',
- id: this.record.id
- },
- listeners: {
- success: {
- fn: function (r) {
- if (r.object && r.object.thumb) {
- panel.view.updateThumb(r.object.thumb);
- }
- }
- }
- }
- });
- }
- },
-
- destroy: function () {
- if (this.vueUploaderInstance && this.vueUploaderInstance.destroy) {
- this.vueUploaderInstance.destroy();
- this.vueUploaderInstance = null;
- }
- ms3.panel.Gallery.superclass.destroy.call(this);
- }
-
-});
-Ext.reg('ms3-gallery-page', ms3.panel.Gallery);
diff --git a/assets/components/minishop3/js/mgr/product/gallery/gallery.toolbar.js b/assets/components/minishop3/js/mgr/product/gallery/gallery.toolbar.js
deleted file mode 100644
index 1aa8c127..00000000
--- a/assets/components/minishop3/js/mgr/product/gallery/gallery.toolbar.js
+++ /dev/null
@@ -1,77 +0,0 @@
-ms3.panel.Toolbar = function (config) {
- config = config || {};
-
- Ext.apply(config, {
- id: 'ms3-gallery-page-toolbar',
- items: [{
- text: ' ',
- cls: 'ms3-btn-actions',
- menu: [{
- text: ' ' + _('ms3_gallery_file_generate_all'),
- cls: 'ms3-btn-action',
- handler: function () {
- this.fileAction('generateAllThumbs')
- },
- scope: this,
- }, '-', {
- text: ' ' + _('ms3_gallery_file_delete_all'),
- cls: 'ms3-btn-action',
- handler: function () {
- this.fileAction('deleteAllFiles')
- },
- scope: this,
- },]
- },'->', {
- xtype: 'displayfield',
- html: '' + _('ms3_product_source') + ': '
- }, '-', {
- xtype: 'ms3-combo-source',
- id: 'ms3-resource-source',
- description: '[[+source_id]]
' + _('ms3_product_source_help'),
- value: config.record.source_id || config.record.source,
- name: 'source_id',
- hiddenName: 'source_id',
- listeners: {
- select: {
- fn: this.sourceWarning,
- scope: this
- }
- }
- }]
- });
- ms3.panel.Toolbar.superclass.constructor.call(this, config);
- this.config = config;
-};
-Ext.extend(ms3.panel.Toolbar, Ext.Toolbar, {
-
- sourceWarning: function (combo) {
- const source_id = this.config.record.source_id || this.config.record.source;
- const sel_id = combo.getValue();
- if (source_id !== sel_id) {
- Ext.Msg.confirm(_('warning'), _('ms3_product_change_source_confirm'), function (e) {
- if (e === 'yes') {
- combo.setValue(sel_id);
- MODx.activePage.submitForm({
- success: {
- fn: function (r) {
- var page = 'resource/update';
- MODx.loadPage(page, 'id=' + r.result.object.id);
- }, scope: this
- }
- });
- } else {
- combo.setValue(source_id);
- }
- }, this);
- }
- },
-
- fileAction: function (method) {
- const view = Ext.getCmp('ms3-gallery-images-view');
- if (view && typeof view[method] === 'function') {
- return view[method].call(view, arguments);
- }
- },
-
-});
-Ext.reg('ms3-gallery-page-toolbar', ms3.panel.Toolbar);
diff --git a/assets/components/minishop3/js/mgr/product/gallery/gallery.view.js b/assets/components/minishop3/js/mgr/product/gallery/gallery.view.js
deleted file mode 100644
index 35d8bf3b..00000000
--- a/assets/components/minishop3/js/mgr/product/gallery/gallery.view.js
+++ /dev/null
@@ -1,435 +0,0 @@
-ms3.panel.Images = function (config) {
- config = config || {};
-
- this.view = MODx.load({
- xtype: 'ms3-gallery-images-view',
- id: 'ms3-gallery-images-view',
- cls: 'ms3-gallery-images',
- containerScroll: true,
- pageSize: parseInt(config.pageSize || MODx.config.default_per_page),
- product_id: config.product_id,
- emptyText: _('ms3_gallery_emptymsg'),
- });
-
- Ext.applyIf(config, {
- id: 'ms3-gallery-images',
- cls: 'browser-view',
- border: false,
- items: [this.view],
- tbar: this.getTopBar(config),
- bbar: this.getBottomBar(config),
- });
- ms3.panel.Images.superclass.constructor.call(this, config);
-
- const dv = this.view;
- dv.on('render', function () {
- dv.dragZone = new ms3.DragZone(dv);
- dv.dropZone = new ms3.DropZone(dv);
- });
-};
-Ext.extend(ms3.panel.Images, MODx.Panel, {
-
- _doSearch: function (tf) {
- this.view.getStore().baseParams.query = tf.getValue();
- this.getBottomToolbar().changePage(1);
- },
-
- _clearSearch: function () {
- this.view.getStore().baseParams.query = '';
- this.getBottomToolbar().changePage(1);
- },
-
- getTopBar: function () {
- return new Ext.Toolbar({
- items: ['->', {
- xtype: 'ms3-field-search',
- width: 300,
- listeners: {
- search: {
- fn: function (field) {
- //noinspection JSUnresolvedFunction
- this._doSearch(field);
- }, scope: this
- },
- clear: {
- fn: function (field) {
- field.setValue('');
- //noinspection JSUnresolvedFunction
- this._clearSearch();
- }, scope: this
- },
- },
- }]
- })
- },
-
- getBottomBar: function (config) {
- return new Ext.PagingToolbar({
- pageSize: parseInt(config.pageSize || MODx.config.default_per_page),
- store: this.view.store,
- displayInfo: true,
- autoLoad: true,
- items: ['-',
- _('per_page') + ':',
- {
- xtype: 'textfield',
- value: parseInt(config.pageSize || MODx.config.default_per_page),
- width: 50,
- listeners: {
- change: {
- fn: function (tf, nv) {
- if (Ext.isEmpty(nv)) {
- return;
- }
- nv = parseInt(nv);
- //noinspection JSUnresolvedFunction
- this.getBottomToolbar().pageSize = nv;
- this.view.getStore().load({params: {start: 0, limit: nv}});
- }, scope: this
- },
- render: {
- fn: function (cmp) {
- new Ext.KeyMap(cmp.getEl(), {
- key: Ext.EventObject.ENTER,
- fn: function () {
- this.fireEvent('change', this.getValue());
- this.blur();
- return true;
- },
- scope: cmp
- });
- }, scope: this
- }
- }
- }
- ]
- });
- },
-
-});
-Ext.reg('ms3-gallery-images-panel', ms3.panel.Images);
-
-
-ms3.view.Images = function (config) {
- config = config || {};
-
- this._initTemplates();
-
- Ext.applyIf(config, {
- url: ms3.config.connector_url,
- fields: [
- 'id', 'product_id', 'name', 'description', 'url', 'createdon', 'createdby', 'file',
- 'thumbnail', 'source', 'source_name', 'type', 'position', 'active', 'properties', 'class',
- 'add', 'alt', 'actions'
- ],
- id: 'ms3-gallery-images-view',
- baseParams: {
- action: 'MiniShop3\\Processors\\Gallery\\GetList',
- product_id: config.product_id,
- parent: 0,
- type: 'image',
- limit: config.pageSize || MODx.config.default_per_page
- },
- //loadingText: _('loading'),
- enableDD: true,
- multiSelect: true,
- tpl: this.templates.thumb,
- itemSelector: 'div.modx-browser-thumb-wrap',
- listeners: {},
- prepareData: this.formatData.createDelegate(this)
- });
- ms3.view.Images.superclass.constructor.call(this, config);
-
- this.addEvents('sort', 'select');
- this.on('sort', this.onSort, this);
- this.on('dblclick', this.onDblClick, this);
-
- const widget = this;
- this.getStore().on('beforeload', function () {
- widget.getEl().mask(_('loading'), 'x-mask-loading');
- });
- this.getStore().on('load', function () {
- widget.getEl().unmask();
- });
-};
-Ext.extend(ms3.view.Images, MODx.DataView, {
-
- templates: {},
- windows: {},
-
- onSort: function (o) {
- const el = this.getEl();
- console.log('onSort', el)
- el.mask(_('loading'), 'x-mask-loading');
- MODx.Ajax.request({
- url: ms3.config.connector_url,
- params: {
- action: 'MiniShop3\\Processors\\Gallery\\Sort',
- product_id: this.config.product_id,
- source_id: o.source.id,
- target_id: o.target.id
- },
- listeners: {
- success: {
- fn: function (r) {
- el.unmask();
- this.store.reload();
- //noinspection JSUnresolvedFunction
- this.updateThumb(r.object['thumb']);
- }, scope: this
- }
- }
- });
- },
-
- onDblClick: function (e) {
- const node = this.getSelectedNodes()[0];
- if (!node) {
- return;
- }
-
- this.cm.activeNode = node;
- this.updateFile(node, e);
- },
-
- updateFile: function (btn, e) {
- const node = this.cm.activeNode;
- const data = this.lookup[node.id];
- if (!data) {
- return;
- }
-
- const w = MODx.load({
- xtype: 'ms3-gallery-image',
- record: data,
- listeners: {
- success: {
- fn: function () {
- this.store.reload()
- }, scope: this
- }
- }
- });
- w.setValues(data);
- w.show(e.target);
- },
-
- showFile: function () {
- const node = this.cm.activeNode;
- const data = this.lookup[node.id];
- if (!data) {
- return;
- }
-
- window.open(data.url);
- },
-
- fileAction: function (method) {
- const ids = this._getSelectedIds();
- if (!ids.length) {
- return false;
- }
- this.getEl().mask(_('loading'), 'x-mask-loading');
- MODx.Ajax.request({
- url: ms3.config.connector_url,
- params: {
- action: 'MiniShop3\\Processors\\Gallery\\Multiple',
- method: method,
- ids: Ext.util.JSON.encode(ids),
- },
- listeners: {
- success: {
- fn: function (r) {
- if (method === 'Remove') {
- //noinspection JSUnresolvedFunction
- this.updateThumb(r.object['thumb']);
- }
- this.store.reload();
- }, scope: this
- },
- failure: {
- fn: function (response) {
- MODx.msg.alert(_('error'), response.message);
- }, scope: this
- },
- }
- })
- },
-
- deleteFiles: function () {
- const ids = this._getSelectedIds();
- const title = ids.length > 1
- ? 'ms3_gallery_file_delete_multiple'
- : 'ms3_gallery_file_delete';
- const message = ids.length > 1
- ? 'ms3_gallery_file_delete_multiple_confirm'
- : 'ms3_gallery_file_delete_confirm';
- Ext.MessageBox.confirm(
- _(title),
- _(message),
- function (val) {
- if (val == 'yes') {
- this.fileAction('Remove');
- }
- },
- this
- );
- },
-
- deleteAllFiles: function () {
- const product_id = this.config.product_id || '';
-
- Ext.MessageBox.confirm(
- _('ms3_gallery_file_delete_multiple'),
- _('ms3_gallery_file_delete_multiple_confirm'),
- function (val) {
- if (val == 'yes') {
- this.getEl().mask(_('loading'), 'x-mask-loading');
- MODx.Ajax.request({
- url: ms3.config.connector_url,
- params: {
- action: 'MiniShop3\\Processors\\Gallery\\RemoveAll',
- product_id: product_id,
- },
- listeners: {
- success: {
- fn: function (r) {
- //noinspection JSUnresolvedFunction
- this.updateThumb(r.object['thumb']);
- this.store.reload();
- }, scope: this
- },
- failure: {
- fn: function (response) {
- MODx.msg.alert(_('error'), response.message);
- }, scope: this
- },
- }
- })
- }
- },
- this
- );
- },
-
- generateThumbs: function () {
- this.fileAction('Generate');
- },
-
- generateAllThumbs: function () {
- const product_id = this.config.product_id || '';
-
- Ext.MessageBox.confirm(
- _('ms3_gallery_file_generate_thumbs'),
- _('ms3_gallery_file_generate_thumbs_confirm'),
- function (val) {
- if (val == 'yes') {
- this.getEl().mask(_('loading'), 'x-mask-loading');
- MODx.Ajax.request({
- url: ms3.config.connector_url,
- params: {
- action: 'MiniShop3\\Processors\\Gallery\\GenerateAll',
- product_id: product_id,
- },
- listeners: {
- success: {
- fn: function (r) {
- //noinspection JSUnresolvedFunction
- this.updateThumb(r.object['thumb']);
- this.store.reload();
- }, scope: this
- },
- failure: {
- fn: function (response) {
- MODx.msg.alert(_('error'), response.message);
- }, scope: this
- },
- }
- })
- }
- },
- this
- );
- },
-
- updateThumb: function (url) {
- const thumb = Ext.get('ms3-product-image');
- if (thumb && url) {
- thumb.set({'src': url});
- }
- },
-
- run: function (p) {
- p = p || {};
- const v = {};
- Ext.apply(v, this.store.baseParams);
- Ext.apply(v, p);
- this.changePage(1);
- this.store.baseParams = v;
- this.store.load();
- },
-
- formatData: function (data) {
- data.shortName = Ext.util.Format.ellipsis(data.name, 20);
- data.createdon = ms3.utils.formatDate(data.createdon);
- data.size = (data.properties['width'] && data.properties['height'])
- ? data.properties['width'] + 'x' + data.properties['height']
- : '';
- if (data.properties['size'] && data.size) {
- data.size += ', ';
- }
- data.size += data.properties['size']
- ? ms3.utils.formatSize(data.properties['size'])
- : '';
- this.lookup['ms3_-gallery-image-' + data.id] = data;
- return data;
- },
-
- _initTemplates: function () {
- this.templates.thumb = new Ext.XTemplate(
- '\
- \
-
\
-

\
-
\
-
{position}. {shortName}\
-
\
- '
- );
- this.templates.thumb.compile();
- },
-
- _showContextMenu: function (v, i, n, e) {
- e.preventDefault();
- const data = this.lookup[n.id];
- const m = this.cm;
- m.removeAll();
-
- const menu = ms3.utils.getMenu(data.actions, this, this._getSelectedIds());
- for (const item in menu) {
- if (!menu.hasOwnProperty(item)) {
- continue;
- }
- m.add(menu[item]);
- }
-
- m.show(n, 'tl-c?');
- m.activeNode = n;
- },
-
- _getSelectedIds: function () {
- var ids = [];
- const selected = this.getSelectedRecords();
-
- for (const i in selected) {
- if (!selected.hasOwnProperty(i)) {
- continue;
- }
- ids.push(selected[i]['id']);
- }
-
- return ids;
- },
-
-});
-Ext.reg('ms3-gallery-images-view', ms3.view.Images);
diff --git a/assets/components/minishop3/js/mgr/product/gallery/gallery.window.js b/assets/components/minishop3/js/mgr/product/gallery/gallery.window.js
deleted file mode 100644
index 82e59f0a..00000000
--- a/assets/components/minishop3/js/mgr/product/gallery/gallery.window.js
+++ /dev/null
@@ -1,93 +0,0 @@
-ms3.window.Image = function (config) {
- config = config || {};
-
- Ext.applyIf(config, {
- title: config.record['name'],
- width: 700,
- baseParams: {
- action: 'MiniShop3\\Processors\\Gallery\\Update',
- },
- resizable: false,
- maximizable: false,
- minimizable: false,
- });
- ms3.window.Image.superclass.constructor.call(this, config);
-};
-Ext.extend(ms3.window.Image, ms3.window.Default, {
-
- getFields: function (config) {
- const src = config.record['type'] === 'image'
- ? config.record['url']
- : config.record['thumbnail'];
- const img = MODx.config['connectors_url'] + 'system/phpthumb.php?src='
- + src
- + '&w=333&h=198&f=jpg&q=90&zc=0&far=1&HTTP_MODAUTH='
- + MODx.siteId + '&wctx=mgr&source='
- + config.record['source'];
- const fields = {
- ms3_gallery_file_source: config.record['source_name'],
- ms3_gallery_file_size: config.record['size'],
- ms3_gallery_file_createdon: config.record['createdon'],
- };
- let details = '';
- for (const i in fields) {
- if (!fields.hasOwnProperty(i)) {
- continue;
- }
- if (fields[i]) {
- details += '| ' + _(i) + ': | ' + fields[i] + ' |
';
- }
- }
-
- return [
- {xtype: 'hidden', name: 'id', id: this.ident + '-id'},
- {
- layout: 'column',
- border: false,
- anchor: '100%',
- items: [{
- columnWidth: .5,
- layout: 'form',
- defaults: {msgTarget: 'under'},
- border: false,
- items: [{
- xtype: 'displayfield',
- hideLabel: true,
- html: '\
- \
-
\
- \
- '
- }]
- }, {
- columnWidth: .5,
- layout: 'form',
- defaults: {msgTarget: 'under'},
- border: false,
- items: [{
- xtype: 'textfield',
- fieldLabel: _('ms3_gallery_file_name'),
- name: 'file',
- id: this.ident + '-file',
- anchor: '100%'
- }, {
- xtype: 'textfield',
- fieldLabel: _('ms3_gallery_file_title'),
- name: 'name',
- id: this.ident + '-name',
- anchor: '100%'
- }, {
- xtype: 'textarea',
- fieldLabel: _('ms3_gallery_file_description'),
- name: 'description',
- id: this.ident + '-description',
- anchor: '100%',
- height: 100
- }]
- }]
- }
- ];
- }
-
-});
-Ext.reg('ms3-gallery-image', ms3.window.Image);
diff --git a/core/components/minishop3/controllers/product/update.class.php b/core/components/minishop3/controllers/product/update.class.php
index 83228e65..195b4baf 100644
--- a/core/components/minishop3/controllers/product/update.class.php
+++ b/core/components/minishop3/controllers/product/update.class.php
@@ -64,19 +64,12 @@ public function loadCustomCssJs()
// Product Tabs Vue module (contains Properties, Gallery, Categories, Links, Options tabs)
$this->addCss($assetsUrl . 'css/mgr/vue-dist/primeicons.min.css');
$this->addCss($assetsUrl . 'css/mgr/vue-dist/product-tabs.min.css');
- $this->addVueModule($assetsUrl . 'js/mgr/vue-dist/product-tabs.min.js');
-
$show_gallery = $this->getOption('ms3_product_tab_gallery', null, true);
if ($show_gallery) {
+ // Uppy styles for Gallery tab (GalleryUploader is in a separate chunk, its CSS must be loaded explicitly)
$this->addCss($assetsUrl . 'css/mgr/vue-dist/gallery-uploader.min.css');
- // Vue module with VueTools dependency check
- $this->addVueModule($assetsUrl . 'js/mgr/vue-dist/gallery-uploader.min.js');
- $this->addLastJavascript($assetsUrl . 'js/mgr/misc/ext.ddview.js');
- $this->addLastJavascript($assetsUrl . 'js/mgr/product/gallery/gallery.panel.js');
- $this->addLastJavascript($assetsUrl . 'js/mgr/product/gallery/gallery.toolbar.js');
- $this->addLastJavascript($assetsUrl . 'js/mgr/product/gallery/gallery.view.js');
- $this->addLastJavascript($assetsUrl . 'js/mgr/product/gallery/gallery.window.js');
}
+ $this->addVueModule($assetsUrl . 'js/mgr/vue-dist/product-tabs.min.js');
// Customizable product fields feature
$product_fields = array_merge($this->resource->getAllFieldsNames(), ['syncsite']);
@@ -121,6 +114,7 @@ public function loadCustomCssJs()
'show_links' => (bool)$this->getOption('ms3_product_tab_links', null, true),
'show_categories' => (bool)$this->getOption('ms3_product_tab_categories', null, true),
'default_thumb' => $this->ms3->config['defaultThumb'],
+ 'sources' => $this->getMediaSourcesList(),
'main_fields' => $product_main_fields,
'extra_fields' => $product_extra_fields,
'option_keys' => $product_option_keys,
@@ -207,6 +201,28 @@ public function prepareFields()
}
}
+ /**
+ * Get list of media sources for gallery source selector
+ *
+ * @return array List of [id => int, name => string]
+ */
+ public function getMediaSourcesList()
+ {
+ $list = [];
+ $sources = $this->modx->getCollection('sources.modMediaSource', ['id:>' => 0]);
+ /** @var \MODX\Revolution\Sources\modMediaSource $source */
+ foreach ($sources as $source) {
+ if (!$source->checkPolicy('view')) {
+ continue;
+ }
+ $list[] = [
+ 'id' => (int)$source->get('id'),
+ 'name' => $source->get('name') ?: ('Source ' . $source->get('id')),
+ ];
+ }
+ return $list;
+ }
+
/**
* Load media source properties
*
diff --git a/core/components/minishop3/lexicon/en/product.inc.php b/core/components/minishop3/lexicon/en/product.inc.php
index 4b64a40d..9903ad46 100644
--- a/core/components/minishop3/lexicon/en/product.inc.php
+++ b/core/components/minishop3/lexicon/en/product.inc.php
@@ -135,6 +135,7 @@
$_lang['ms3_product_selected_undelete'] = 'Restore Selected';
$_lang['ms3_gallery_emptymsg'] = 'No files found.
You can upload them by dragging directly to this panel or by clicking the button above.
';
+$_lang['ms3_gallery_empty_text'] = 'No files yet. Drag files into the upload area above or click «Select files».';
$_lang['ms3_gallery_unavailablemsg'] = 'To upload files to Gallery, you need to create (save) the product first.';
$_lang['ms3_gallery_file_name'] = 'File Name';
$_lang['ms3_gallery_file_title'] = 'Title';
@@ -156,7 +157,7 @@
$_lang['ms3_gallery_file_delete_all'] = 'Delete All';
$_lang['ms3_gallery_file_delete_confirm'] = 'Are you sure you want to delete this file with all its thumbnails?
This operation is irreversible.';
$_lang['ms3_gallery_file_delete_multiple'] = 'Delete Files';
-$_lang['ms3_gallery_file_delete_multiple_confirm'] = 'Are you sure you want to delete these files with all their thumbnails?
This operation is irreversible.';
+$_lang['ms3_gallery_file_delete_multiple_confirm'] = 'Are you sure you want to delete these files with all their thumbnails? This operation is irreversible.';
$_lang['ms3_gallery_errors'] = 'Upload Errors';
@@ -167,6 +168,7 @@
$_lang['ms3_gallery_uppy_browse_folders'] = 'browse folders';
$_lang['ms3_gallery_uppy_upload_complete'] = 'Upload complete';
$_lang['ms3_gallery_uppy_upload_failed'] = 'Upload failed';
+$_lang['ms3_gallery_uppy_failed_to_upload'] = 'Failed to upload %{file}';
$_lang['ms3_gallery_uppy_uploading'] = 'Uploading...';
$_lang['ms3_gallery_uppy_complete'] = 'Complete';
$_lang['ms3_gallery_uppy_cancel'] = 'Cancel';
@@ -181,6 +183,50 @@
$_lang['ms3_gallery_uppy_upload_x_files_1'] = 'Upload %{smart_count} files';
$_lang['ms3_gallery_uppy_upload_x_files_2'] = 'Upload %{smart_count} files';
$_lang['ms3_gallery_uppy_note_max_size'] = 'Maximum size: %{maxSize}';
+$_lang['ms3_gallery_uppy_back'] = 'Back';
+$_lang['ms3_gallery_uppy_add_more_files'] = 'Add more files';
+$_lang['ms3_gallery_uppy_adding_more'] = 'Adding more files';
+$_lang['ms3_gallery_uppy_duplicate_file'] = "Cannot add the duplicate file '%{fileName}', it already exists";
+$_lang['ms3_gallery_uppy_restrictions_failed'] = '%{count} additional restrictions were not fulfilled';
+$_lang['ms3_gallery_uppy_done'] = 'Done';
+$_lang['ms3_gallery_uppy_upload'] = 'Upload';
+$_lang['ms3_gallery_uppy_pause'] = 'Pause';
+$_lang['ms3_gallery_uppy_resume'] = 'Resume';
+$_lang['ms3_gallery_uppy_paused'] = 'Paused';
+$_lang['ms3_gallery_uppy_save'] = 'Save';
+$_lang['ms3_gallery_uppy_save_changes'] = 'Save changes';
+$_lang['ms3_gallery_uppy_finish_editing_file'] = 'Finish editing file';
+$_lang['ms3_gallery_uppy_editing'] = 'Editing %{file}';
+$_lang['ms3_gallery_uppy_remove_file'] = 'Remove file';
+$_lang['ms3_gallery_uppy_edit_file'] = 'Edit file';
+$_lang['ms3_gallery_uppy_edit_image'] = 'Edit image';
+$_lang['ms3_gallery_uppy_drop_hint'] = 'Drop your files here';
+$_lang['ms3_gallery_uppy_error'] = 'Error';
+$_lang['ms3_gallery_uppy_show_error_details'] = 'Show error details';
+$_lang['ms3_gallery_uppy_upload_paused'] = 'Upload paused';
+$_lang['ms3_gallery_uppy_resume_upload'] = 'Resume upload';
+$_lang['ms3_gallery_uppy_pause_upload'] = 'Pause upload';
+$_lang['ms3_gallery_uppy_retry_upload'] = 'Retry upload';
+$_lang['ms3_gallery_uppy_cancel_upload'] = 'Cancel upload';
+$_lang['ms3_gallery_uppy_uploading_x_files_0'] = 'Uploading %{smart_count} file';
+$_lang['ms3_gallery_uppy_uploading_x_files_1'] = 'Uploading %{smart_count} files';
+$_lang['ms3_gallery_uppy_processing_x_files_0'] = 'Processing %{smart_count} file';
+$_lang['ms3_gallery_uppy_processing_x_files_1'] = 'Processing %{smart_count} files';
+$_lang['ms3_gallery_uppy_powered_by'] = 'Powered by %{uppy}';
+$_lang['ms3_gallery_uppy_files_uploaded_of_total_0'] = '%{complete} of %{smart_count} file uploaded';
+$_lang['ms3_gallery_uppy_files_uploaded_of_total_1'] = '%{complete} of %{smart_count} files uploaded';
+$_lang['ms3_gallery_uppy_data_uploaded_of_total'] = '%{complete} of %{total}';
+$_lang['ms3_gallery_uppy_data_uploaded_of_unknown'] = '%{complete} of unknown';
+$_lang['ms3_gallery_uppy_x_time_left'] = '%{time} left';
+$_lang['ms3_gallery_uppy_upload_x_new_files_0'] = 'Upload +%{smart_count} file';
+$_lang['ms3_gallery_uppy_upload_x_new_files_1'] = 'Upload +%{smart_count} files';
+$_lang['ms3_gallery_uppy_x_more_files_added_0'] = '%{smart_count} more file added';
+$_lang['ms3_gallery_uppy_x_more_files_added_1'] = '%{smart_count} more files added';
+$_lang['ms3_gallery_uppy_close_modal'] = 'Close Modal';
+$_lang['ms3_gallery_uppy_response_error'] = 'Server returned an invalid response. Check server logs for PHP errors.';
+$_lang['ms3_gallery_uppy_dashboard_title'] = 'Uppy Dashboard';
+$_lang['ms3_gallery_search_placeholder'] = 'Search by file name...';
+$_lang['ms3_gallery_drag_hint'] = 'Drag to reorder';
$_lang['ms3_product_data_vue'] = 'Product Data (Vue)';
diff --git a/core/components/minishop3/lexicon/ru/product.inc.php b/core/components/minishop3/lexicon/ru/product.inc.php
index 2b883191..efc28d83 100644
--- a/core/components/minishop3/lexicon/ru/product.inc.php
+++ b/core/components/minishop3/lexicon/ru/product.inc.php
@@ -137,6 +137,7 @@
//$_lang['ms3_disabled_while_creating'] = 'Эта функция отключена при создании нового товара.';
$_lang['ms3_gallery_emptymsg'] = 'Файлов не найдено.
Вы можете загрузить их, перетащив прямо на эту панель или выбрав кнопкой вверху.
';
+$_lang['ms3_gallery_empty_text'] = 'Файлов нет. Перетащите файлы в область загрузки выше или нажмите «Выбрать файлы».';
$_lang['ms3_gallery_unavailablemsg'] = 'Для загрузки файлов в Галерею необходимо сначала создать (сохранить) товар.';
$_lang['ms3_gallery_file_name'] = 'Имя файла';
$_lang['ms3_gallery_file_title'] = 'Название';
@@ -158,7 +159,7 @@
$_lang['ms3_gallery_file_delete_all'] = 'Удалить все';
$_lang['ms3_gallery_file_delete_confirm'] = 'Вы действительно хотите удалить этот файл вместе со всеми его уменьшенными копиями?
Эта операция необратима.';
$_lang['ms3_gallery_file_delete_multiple'] = 'Удалить файлы';
-$_lang['ms3_gallery_file_delete_multiple_confirm'] = 'Вы действительно хотите удалить эти файлы со всеми их уменьшенными копиями?
Эта операция необратима.';
+$_lang['ms3_gallery_file_delete_multiple_confirm'] = 'Вы действительно хотите удалить эти файлы со всеми их уменьшенными копиями? Эта операция необратима.';
$_lang['ms3_gallery_errors'] = 'Ошибки при загрузке';
@@ -169,6 +170,7 @@
$_lang['ms3_gallery_uppy_browse_folders'] = 'выбрать папки';
$_lang['ms3_gallery_uppy_upload_complete'] = 'Загрузка завершена';
$_lang['ms3_gallery_uppy_upload_failed'] = 'Ошибка загрузки';
+$_lang['ms3_gallery_uppy_failed_to_upload'] = 'Ошибка загрузки %{file}';
$_lang['ms3_gallery_uppy_uploading'] = 'Загрузка...';
$_lang['ms3_gallery_uppy_complete'] = 'Готово';
$_lang['ms3_gallery_uppy_cancel'] = 'Отмена';
@@ -183,5 +185,49 @@
$_lang['ms3_gallery_uppy_upload_x_files_1'] = 'Загрузить %{smart_count} файла';
$_lang['ms3_gallery_uppy_upload_x_files_2'] = 'Загрузить %{smart_count} файлов';
$_lang['ms3_gallery_uppy_note_max_size'] = 'Макс. размер: %{maxSize}';
+$_lang['ms3_gallery_uppy_back'] = 'Назад';
+$_lang['ms3_gallery_uppy_add_more_files'] = 'Добавить файлы';
+$_lang['ms3_gallery_uppy_adding_more'] = 'Добавление файлов';
+$_lang['ms3_gallery_uppy_duplicate_file'] = "Нельзя добавить файл «%{fileName}» — он уже добавлен";
+$_lang['ms3_gallery_uppy_restrictions_failed'] = 'Ещё %{count} ограничений не выполнено';
+$_lang['ms3_gallery_uppy_done'] = 'Готово';
+$_lang['ms3_gallery_uppy_upload'] = 'Загрузить';
+$_lang['ms3_gallery_uppy_pause'] = 'Пауза';
+$_lang['ms3_gallery_uppy_resume'] = 'Продолжить';
+$_lang['ms3_gallery_uppy_paused'] = 'На паузе';
+$_lang['ms3_gallery_uppy_save'] = 'Сохранить';
+$_lang['ms3_gallery_uppy_save_changes'] = 'Сохранить изменения';
+$_lang['ms3_gallery_uppy_finish_editing_file'] = 'Завершить редактирование';
+$_lang['ms3_gallery_uppy_editing'] = 'Редактирование %{file}';
+$_lang['ms3_gallery_uppy_remove_file'] = 'Удалить файл';
+$_lang['ms3_gallery_uppy_edit_file'] = 'Редактировать файл';
+$_lang['ms3_gallery_uppy_edit_image'] = 'Редактировать изображение';
+$_lang['ms3_gallery_uppy_drop_hint'] = 'Перетащите файлы сюда';
+$_lang['ms3_gallery_uppy_error'] = 'Ошибка';
+$_lang['ms3_gallery_uppy_show_error_details'] = 'Показать детали ошибки';
+$_lang['ms3_gallery_uppy_upload_paused'] = 'Загрузка на паузе';
+$_lang['ms3_gallery_uppy_resume_upload'] = 'Продолжить загрузку';
+$_lang['ms3_gallery_uppy_pause_upload'] = 'Приостановить загрузку';
+$_lang['ms3_gallery_uppy_retry_upload'] = 'Повторить загрузку';
+$_lang['ms3_gallery_uppy_cancel_upload'] = 'Отменить загрузку';
+$_lang['ms3_gallery_uppy_uploading_x_files_0'] = 'Загрузка %{smart_count} файла';
+$_lang['ms3_gallery_uppy_uploading_x_files_1'] = 'Загрузка %{smart_count} файлов';
+$_lang['ms3_gallery_uppy_processing_x_files_0'] = 'Обработка %{smart_count} файла';
+$_lang['ms3_gallery_uppy_processing_x_files_1'] = 'Обработка %{smart_count} файлов';
+$_lang['ms3_gallery_uppy_powered_by'] = 'На базе %{uppy}';
+$_lang['ms3_gallery_uppy_files_uploaded_of_total_0'] = 'Загружено %{complete} из %{smart_count} файла';
+$_lang['ms3_gallery_uppy_files_uploaded_of_total_1'] = 'Загружено %{complete} из %{smart_count} файлов';
+$_lang['ms3_gallery_uppy_data_uploaded_of_total'] = '%{complete} из %{total}';
+$_lang['ms3_gallery_uppy_data_uploaded_of_unknown'] = '%{complete} из неизвестного объёма';
+$_lang['ms3_gallery_uppy_x_time_left'] = 'Осталось %{time}';
+$_lang['ms3_gallery_uppy_upload_x_new_files_0'] = 'Загрузить +%{smart_count} файл';
+$_lang['ms3_gallery_uppy_upload_x_new_files_1'] = 'Загрузить +%{smart_count} файлов';
+$_lang['ms3_gallery_uppy_x_more_files_added_0'] = 'Добавлен ещё %{smart_count} файл';
+$_lang['ms3_gallery_uppy_x_more_files_added_1'] = 'Добавлено ещё %{smart_count} файлов';
+$_lang['ms3_gallery_uppy_close_modal'] = 'Закрыть';
+$_lang['ms3_gallery_uppy_response_error'] = 'Сервер вернул некорректный ответ. Проверьте логи PHP на сервере.';
+$_lang['ms3_gallery_uppy_dashboard_title'] = 'Панель загрузки';
+$_lang['ms3_gallery_search_placeholder'] = 'Поиск по имени файла...';
+$_lang['ms3_gallery_drag_hint'] = 'Перетащите для изменения порядка';
$_lang['ms3_product_data_vue'] = 'Данные товара (Vue)';
diff --git a/core/components/minishop3/src/Processors/Gallery/Upload.php b/core/components/minishop3/src/Processors/Gallery/Upload.php
index 9d9915e6..56d7a563 100644
--- a/core/components/minishop3/src/Processors/Gallery/Upload.php
+++ b/core/components/minishop3/src/Processors/Gallery/Upload.php
@@ -175,6 +175,11 @@ public function process()
}
}
+ $this->modx->log(
+ modX::LOG_LEVEL_DEBUG,
+ '[ms3Gallery] Upload success, file id=' . $uploaded_file->get('id')
+ );
+
return $this->success('', $uploaded_file);
} else {
return $this->failure($this->modx->lexicon('ms3_err_gallery_save') . ': ' .
diff --git a/core/components/minishop3/src/Processors/Product/UpdateSource.php b/core/components/minishop3/src/Processors/Product/UpdateSource.php
new file mode 100644
index 00000000..7cb92aed
--- /dev/null
+++ b/core/components/minishop3/src/Processors/Product/UpdateSource.php
@@ -0,0 +1,55 @@
+getProperty('id');
+ if ($id <= 0) {
+ return $this->modx->lexicon('invalid_data');
+ }
+
+ $this->product = $this->modx->getObject(msProduct::class, $id);
+ if (!$this->product) {
+ return $this->modx->lexicon('resource_err_nfs', ['id' => $id]);
+ }
+
+ return parent::initialize();
+ }
+
+ /**
+ * @return array|string
+ */
+ public function process()
+ {
+ $sourceId = (int)$this->getProperty('source_id');
+ if ($sourceId < 0) {
+ return $this->failure($this->modx->lexicon('invalid_data'));
+ }
+
+ $this->product->set('source_id', $sourceId);
+ if (!$this->product->save()) {
+ return $this->failure($this->modx->lexicon('ms3_err_save'));
+ }
+
+ return $this->success();
+ }
+}
diff --git a/vueManager/src/components/gallery/GalleryUploader.vue b/vueManager/src/components/gallery/GalleryUploader.vue
index ae148768..8f54029f 100644
--- a/vueManager/src/components/gallery/GalleryUploader.vue
+++ b/vueManager/src/components/gallery/GalleryUploader.vue
@@ -66,16 +66,24 @@ onBeforeUnmount(() => {
}
})
+/**
+ * Локализация Uppy Dashboard (строки интерфейса и плюрализация для ru/en).
+ */
const buildUppyLocale = () => {
const isRu = (window.MODx?.cultureKey || 'en').toLowerCase().startsWith('ru')
return {
strings: {
+ back: _('ms3_gallery_uppy_back'),
+ addMoreFiles: _('ms3_gallery_uppy_add_more_files'),
+ addingMoreFiles: _('ms3_gallery_uppy_adding_more'),
dropPasteFiles: _('ms3_gallery_uppy_drop_paste'),
browse: _('ms3_gallery_uppy_browse'),
browseFiles: _('ms3_gallery_uppy_browse_files'),
browseFolders: _('ms3_gallery_uppy_browse_folders'),
uploadComplete: _('ms3_gallery_uppy_upload_complete'),
uploadFailed: _('ms3_gallery_uppy_upload_failed'),
+ /** Сообщение об ошибке загрузки одного файла (подставляет имя: %{file}) */
+ failedToUpload: _('ms3_gallery_uppy_failed_to_upload'),
uploading: _('ms3_gallery_uppy_uploading'),
complete: _('ms3_gallery_uppy_complete'),
cancel: _('ms3_gallery_uppy_cancel'),
@@ -93,6 +101,54 @@ const buildUppyLocale = () => {
1: _('ms3_gallery_uppy_upload_x_files_1'),
2: _('ms3_gallery_uppy_upload_x_files_2'),
},
+ noDuplicates: _('ms3_gallery_uppy_duplicate_file'),
+ additionalRestrictionsFailed: _('ms3_gallery_uppy_restrictions_failed'),
+ done: _('ms3_gallery_uppy_done'),
+ upload: _('ms3_gallery_uppy_upload'),
+ pause: _('ms3_gallery_uppy_pause'),
+ resume: _('ms3_gallery_uppy_resume'),
+ paused: _('ms3_gallery_uppy_paused'),
+ save: _('ms3_gallery_uppy_save'),
+ saveChanges: _('ms3_gallery_uppy_save_changes'),
+ finishEditingFile: _('ms3_gallery_uppy_finish_editing_file'),
+ editing: _('ms3_gallery_uppy_editing'),
+ removeFile: _('ms3_gallery_uppy_remove_file'),
+ editFile: _('ms3_gallery_uppy_edit_file'),
+ editImage: _('ms3_gallery_uppy_edit_image'),
+ dropHint: _('ms3_gallery_uppy_drop_hint'),
+ error: _('ms3_gallery_uppy_error'),
+ showErrorDetails: _('ms3_gallery_uppy_show_error_details'),
+ uploadPaused: _('ms3_gallery_uppy_upload_paused'),
+ resumeUpload: _('ms3_gallery_uppy_resume_upload'),
+ pauseUpload: _('ms3_gallery_uppy_pause_upload'),
+ retryUpload: _('ms3_gallery_uppy_retry_upload'),
+ cancelUpload: _('ms3_gallery_uppy_cancel_upload'),
+ uploadingXFiles: {
+ 0: _('ms3_gallery_uppy_uploading_x_files_0'),
+ 1: _('ms3_gallery_uppy_uploading_x_files_1'),
+ },
+ processingXFiles: {
+ 0: _('ms3_gallery_uppy_processing_x_files_0'),
+ 1: _('ms3_gallery_uppy_processing_x_files_1'),
+ },
+ poweredBy: _('ms3_gallery_uppy_powered_by'),
+ filesUploadedOfTotal: {
+ 0: _('ms3_gallery_uppy_files_uploaded_of_total_0'),
+ 1: _('ms3_gallery_uppy_files_uploaded_of_total_1'),
+ },
+ dataUploadedOfTotal: _('ms3_gallery_uppy_data_uploaded_of_total'),
+ dataUploadedOfUnknown: _('ms3_gallery_uppy_data_uploaded_of_unknown'),
+ xTimeLeft: _('ms3_gallery_uppy_x_time_left'),
+ uploadXNewFiles: {
+ 0: _('ms3_gallery_uppy_upload_x_new_files_0'),
+ 1: _('ms3_gallery_uppy_upload_x_new_files_1'),
+ },
+ xMoreFilesAdded: {
+ 0: _('ms3_gallery_uppy_x_more_files_added_0'),
+ 1: _('ms3_gallery_uppy_x_more_files_added_1'),
+ },
+ closeModal: _('ms3_gallery_uppy_close_modal'),
+ dashboardTitle: _('ms3_gallery_uppy_dashboard_title'),
},
pluralize: isRu
? n =>
@@ -151,6 +207,45 @@ const initUppy = () => {
headers: {
Accept: 'application/json',
},
+ /**
+ * Парсит ответ сервера для Uppy: ожидается JSON с success и object.url/file.
+ * Если в ответ попал мусор (PHP notice/warning перед JSON), извлекается фрагмент между первой { и последней }.
+ */
+ getResponseData(responseTextOrXhr, response) {
+ let text = ''
+ if (typeof responseTextOrXhr === 'string') {
+ text = responseTextOrXhr
+ } else if (responseTextOrXhr && typeof responseTextOrXhr === 'object') {
+ text = responseTextOrXhr.responseText ?? responseTextOrXhr.response ?? ''
+ }
+ text = String(text ?? '').trim()
+
+ let data
+ try {
+ data = JSON.parse(text)
+ } catch (parseErr) {
+ const firstBrace = text.indexOf('{')
+ const lastBrace = text.lastIndexOf('}')
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
+ try {
+ data = JSON.parse(text.slice(firstBrace, lastBrace + 1))
+ } catch (_e) {
+ data = null
+ }
+ }
+ if (!data || typeof data !== 'object') {
+ const errMsg = _('ms3_gallery_uppy_response_error') || 'Server returned an invalid response. Check server logs for PHP errors.'
+ throw new Error(errMsg)
+ }
+ }
+ if (!data || typeof data !== 'object') return {}
+ if (data.success !== true || !data.object) {
+ const msg = data.message || _('ms3_gallery_uppy_upload_failed')
+ throw new Error(msg)
+ }
+ // Uppy ожидает объект с полем url; MODX возвращает object.url или object.file
+ return { ...data, url: data.object.url || data.object.file }
+ },
})
uppy.on('upload-success', (file, response) => {
@@ -159,7 +254,6 @@ const initUppy = () => {
})
uppy.on('upload-error', (file, error, response) => {
- console.error('Upload error:', file?.name, error)
emit('upload-error', { file, error, response })
})
@@ -173,10 +267,6 @@ const initUppy = () => {
})
}, 2000)
})
-
- uppy.on('restriction-failed', (file, error) => {
- console.warn('Restriction failed:', file?.name, error)
- })
}
const buildUploadUrl = () => {
diff --git a/vueManager/src/components/product/ProductGallery.vue b/vueManager/src/components/product/ProductGallery.vue
new file mode 100644
index 00000000..787cd477
--- /dev/null
+++ b/vueManager/src/components/product/ProductGallery.vue
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/vueManager/src/components/product/ProductGalleryEditDialog.vue b/vueManager/src/components/product/ProductGalleryEditDialog.vue
new file mode 100644
index 00000000..1269674f
--- /dev/null
+++ b/vueManager/src/components/product/ProductGalleryEditDialog.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/vueManager/src/components/product/ProductGalleryGrid.vue b/vueManager/src/components/product/ProductGalleryGrid.vue
new file mode 100644
index 00000000..ff20f8e8
--- /dev/null
+++ b/vueManager/src/components/product/ProductGalleryGrid.vue
@@ -0,0 +1,332 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ _('ms3_vue_loading') }}
+
+
+ {{ _('ms3_gallery_empty_text') }}
+
+
+
+
+
+
+
![]()
+
+
{{ item.name || item.file }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vueManager/src/components/product/ProductGalleryToolbar.vue b/vueManager/src/components/product/ProductGalleryToolbar.vue
new file mode 100644
index 00000000..226d50f6
--- /dev/null
+++ b/vueManager/src/components/product/ProductGalleryToolbar.vue
@@ -0,0 +1,162 @@
+
+
+
+
+
+
+
diff --git a/vueManager/src/components/product/ProductTabs.vue b/vueManager/src/components/product/ProductTabs.vue
index fccaf91a..1e448269 100644
--- a/vueManager/src/components/product/ProductTabs.vue
+++ b/vueManager/src/components/product/ProductTabs.vue
@@ -7,9 +7,10 @@ import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
-import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import ProductDataFields from '../ProductDataFields.vue'
+import ProductGallery from './ProductGallery.vue'
const props = defineProps({
productId: {
@@ -29,6 +30,13 @@ const props = defineProps({
const { _ } = useLexicon()
useToast() // Required for Toast component to work
+function updateProductThumb(thumb) {
+ const el = document.getElementById('ms3-product-image')
+ if (el && thumb) el.setAttribute('src', thumb)
+}
+
+provide('updateProductThumb', updateProductThumb)
+
const STORAGE_KEY = 'ms3-product-vue-active-tab'
// Active tab value (index as string for Tabs v4)
@@ -68,13 +76,8 @@ const tabConfig = computed(() => {
tabs.push({
key: 'gallery',
title: _('ms3_tab_product_gallery'),
- type: 'extjs',
- xtype: 'ms3-gallery-page',
- extConfig: {
- record: props.record,
- pageSize: 50,
- border: false,
- },
+ type: 'vue',
+ component: 'ProductGallery',
position: 1,
})
}
@@ -181,38 +184,6 @@ function mountExtJS(tabKey, tabData) {
})
mountedExtComponents.value[tabKey] = extComponent
-
- // For gallery panel - manually call initialize() to init Vue uploader
- // and fix source combo value
- if (tabKey === 'gallery' && typeof extComponent.initialize === 'function') {
- setTimeout(() => {
- extComponent.initialize()
-
- // Fix source combo - set value after store loads
- const sourceCombo = Ext.getCmp('ms3-resource-source')
- if (sourceCombo && tabData.extConfig.record) {
- const sourceValue =
- tabData.extConfig.record.source || tabData.extConfig.record.source_id
- if (sourceValue && sourceCombo.store) {
- if (sourceCombo.store.getCount() > 0) {
- sourceCombo.setValue(sourceValue)
- } else {
- sourceCombo.store.on(
- 'load',
- function () {
- sourceCombo.setValue(sourceValue)
- },
- null,
- { single: true }
- )
- if (!sourceCombo.store.isLoading) {
- sourceCombo.store.load()
- }
- }
- }
- }
- }, 100)
- }
} catch (error) {
console.error(`[ProductTabs] Failed to mount ExtJS component ${tabKey}:`, error)
}
@@ -424,6 +395,11 @@ onBeforeUnmount(() => {
+
+
+
+
+
diff --git a/vueManager/src/composables/useGalleryApi.js b/vueManager/src/composables/useGalleryApi.js
new file mode 100644
index 00000000..c8a3bf70
--- /dev/null
+++ b/vueManager/src/composables/useGalleryApi.js
@@ -0,0 +1,238 @@
+/**
+ * Composable for Gallery API calls via MODX connector.
+ * All functions return Promise; no global state mutation.
+ */
+
+import { ref } from 'vue'
+
+const GALLERY_ACTIONS = {
+ GetList: 'MiniShop3\\Processors\\Gallery\\GetList',
+ Sort: 'MiniShop3\\Processors\\Gallery\\Sort',
+ Multiple: 'MiniShop3\\Processors\\Gallery\\Multiple',
+ RemoveAll: 'MiniShop3\\Processors\\Gallery\\RemoveAll',
+ GenerateAll: 'MiniShop3\\Processors\\Gallery\\GenerateAll',
+ Update: 'MiniShop3\\Processors\\Gallery\\Update',
+}
+const PRODUCT_UPDATE_SOURCE = 'MiniShop3\\Processors\\Product\\UpdateSource'
+
+function getConnectorUrl() {
+ if (typeof ms3 !== 'undefined' && ms3?.config?.connector_url) {
+ return ms3.config.connector_url
+ }
+ return '/assets/components/minishop3/connector.php'
+}
+
+function getModAuth() {
+ if (typeof MODx !== 'undefined' && MODx?.siteId) {
+ return MODx.siteId
+ }
+ return ''
+}
+
+/**
+ * Call MODX connector with action and params.
+ * @param {string} action - Processor class name
+ * @param {Record} params - Request params
+ * @param {'GET'|'POST'} method
+ * @returns {Promise<{ success: boolean, results?: any[], total?: number, object?: object, message?: string }>}
+ */
+async function connectorRequest(action, params, method = 'POST') {
+ const baseUrl = getConnectorUrl()
+ const modAuth = getModAuth()
+ const allParams = {
+ action,
+ ctx: 'mgr',
+ HTTP_MODAUTH: modAuth,
+ ...params,
+ }
+
+ let url = baseUrl
+ const options = { method, credentials: 'same-origin', headers: { Accept: 'application/json' } }
+
+ // Allow empty strings so updateFile can clear description; filter only undefined/null
+ const filteredParams = Object.fromEntries(
+ Object.entries(allParams).filter(([_, v]) => v !== undefined && v !== null)
+ )
+
+ if (method === 'GET') {
+ const search = new URLSearchParams()
+ Object.entries(filteredParams).forEach(([k, v]) => search.set(k, String(v)))
+ url = `${baseUrl}?${search.toString()}`
+ } else {
+ options.body = new URLSearchParams(filteredParams)
+ }
+
+ const response = await fetch(url, options)
+ const text = await response.text()
+ const trimmed = text.trim()
+
+ let data
+ try {
+ data = JSON.parse(text)
+ } catch (e) {
+ const contentType = response.headers.get('Content-Type') || ''
+ const isJsonDeclared = contentType.includes('application/json')
+ const snippet = trimmed.slice(0, 200).replace(/\s+/g, ' ')
+ const err = new Error(
+ isJsonDeclared
+ ? `Gallery API: invalid JSON (${e.message}). Check for PHP errors or HTML in connector response.`
+ : `Gallery API: server returned ${response.status} (expected JSON). ${snippet ? `Response: ${snippet}…` : ''}`
+ )
+ err.status = response.status
+ err.body = text.slice(0, 300)
+ throw err
+ }
+
+ if (data.success === false) {
+ const err = new Error(data.message || 'Request failed')
+ err.response = data
+ err.status = response.status
+ throw err
+ }
+ return data
+}
+
+/**
+ * @param {number} productId
+ * @param {{ query?: string, start?: number, limit?: number }} opts
+ * @returns {Promise<{ results: any[], total: number, thumb?: string }>}
+ */
+export async function fetchGalleryList(productId, opts = {}) {
+ const { query = '', start = 0, limit = 20 } = opts
+ const data = await connectorRequest(
+ GALLERY_ACTIONS.GetList,
+ {
+ product_id: productId,
+ parent_id: 0,
+ type: 'image',
+ query: String(query).trim(),
+ start: Number(start),
+ limit: Number(limit),
+ },
+ 'GET'
+ )
+ return {
+ results: data.results ?? data.data ?? [],
+ total: data.total ?? 0,
+ thumb: data.object?.thumb,
+ }
+}
+
+/**
+ * Изменение порядка файлов в галерее (drag-and-drop).
+ * @param {number} productId
+ * @param {number} sourceId - id перемещаемого файла
+ * @param {number} targetId - id файла, относительно которого ставим (сосед)
+ */
+export async function sortFiles(productId, sourceId, targetId) {
+ // Процессор Gallery\Sort ожидает целочисленные id
+ const data = await connectorRequest(GALLERY_ACTIONS.Sort, {
+ product_id: Number(productId),
+ source_id: Number(sourceId),
+ target_id: Number(targetId),
+ })
+ return { thumb: data.object?.thumb }
+}
+
+/**
+ * @param {number[]} ids - file ids
+ */
+export async function deleteFiles(ids) {
+ if (!ids?.length) return {}
+ await connectorRequest(GALLERY_ACTIONS.Multiple, {
+ method: 'Remove',
+ ids: JSON.stringify(ids),
+ })
+ return {}
+}
+
+/**
+ * @param {number} productId
+ * @returns {Promise<{ thumb?: string }>}
+ */
+export async function deleteAll(productId) {
+ const data = await connectorRequest(GALLERY_ACTIONS.RemoveAll, { product_id: productId })
+ return { thumb: data.object?.thumb }
+}
+
+/**
+ * @param {number[]} ids - file ids
+ */
+export async function regenerateThumbs(ids) {
+ if (!ids?.length) return {}
+ await connectorRequest(GALLERY_ACTIONS.Multiple, {
+ method: 'Generate',
+ ids: JSON.stringify(ids),
+ })
+ return {}
+}
+
+/**
+ * @param {number} productId
+ * @returns {Promise<{ thumb?: string }>}
+ */
+export async function regenerateAll(productId) {
+ const data = await connectorRequest(GALLERY_ACTIONS.GenerateAll, { product_id: productId })
+ return { thumb: data.object?.thumb }
+}
+
+/**
+ * @param {number} id - file id
+ * @param {{ file: string, name?: string, description?: string }} payload
+ */
+export async function updateFile(id, payload) {
+ await connectorRequest(GALLERY_ACTIONS.Update, {
+ id,
+ file: payload.file ?? '',
+ name: payload.name ?? '',
+ description: payload.description ?? '',
+ })
+ return {}
+}
+
+/**
+ * Update product media source and reload page.
+ * @param {number} productId
+ * @param {number} sourceId
+ */
+export async function updateProductSource(productId, sourceId) {
+ await connectorRequest(PRODUCT_UPDATE_SOURCE, {
+ id: productId,
+ source_id: sourceId,
+ })
+ if (typeof location !== 'undefined' && location.reload) {
+ location.reload()
+ }
+}
+
+/**
+ * Composable: gallery API + loading state.
+ * Components use this to get API functions and isLoading.
+ */
+export function useGalleryApi() {
+ const isLoading = ref(false)
+
+ const withLoading = (fn) => {
+ return async (...args) => {
+ isLoading.value = true
+ try {
+ const result = await fn(...args)
+ return result
+ } finally {
+ isLoading.value = false
+ }
+ }
+ }
+
+ return {
+ isLoading,
+ fetchGalleryList: withLoading(fetchGalleryList),
+ sortFiles: withLoading(sortFiles),
+ deleteFiles: withLoading(deleteFiles),
+ deleteAll: withLoading(deleteAll),
+ regenerateThumbs: withLoading(regenerateThumbs),
+ regenerateAll: withLoading(regenerateAll),
+ updateFile: withLoading(updateFile),
+ updateProductSource: withLoading(updateProductSource),
+ }
+}
diff --git a/vueManager/src/entries/product-tabs.js b/vueManager/src/entries/product-tabs.js
index 5c3c41c6..259dcd36 100644
--- a/vueManager/src/entries/product-tabs.js
+++ b/vueManager/src/entries/product-tabs.js
@@ -13,6 +13,7 @@ import Aura from '@primeuix/themes/aura'
import { getPrimeVueLocale } from '@vuetools/usePrimeVueLocale'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
+import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import { createApp } from 'vue'
@@ -139,6 +140,7 @@ function createVueApp(props) {
})
app.use(ToastService)
+ app.use(ConfirmationService)
return app
}
diff --git a/vueManager/src/scss/_variables.scss b/vueManager/src/scss/_variables.scss
index ef997e9c..44d68eea 100644
--- a/vueManager/src/scss/_variables.scss
+++ b/vueManager/src/scss/_variables.scss
@@ -77,6 +77,9 @@
--ms3-spacing-3: 0.75rem;
--ms3-spacing-4: 1rem;
+ /* Modals */
+ --ms3-modal-width: 28rem;
+
/* Radius */
--ms3-radius-sm: 0.25rem;
--ms3-radius-md: 0.375rem;
diff --git a/vueManager/src/scss/primevue.scss b/vueManager/src/scss/primevue.scss
index 39441f63..ef32bd9a 100644
--- a/vueManager/src/scss/primevue.scss
+++ b/vueManager/src/scss/primevue.scss
@@ -296,6 +296,12 @@
gap: 1rem;
}
+/* Modal overflow — единое поведение для Dialog и ConfirmDialog */
+.p-dialog,
+.p-confirm-dialog {
+ overflow-x: hidden;
+}
+
/* Validation Rule Option in Select dropdown */
.p-select-overlay .rule-option,
.p-dialog .rule-option {
diff --git a/vueManager/vite.config.js b/vueManager/vite.config.js
index 59c4ad27..0d07286f 100644
--- a/vueManager/vite.config.js
+++ b/vueManager/vite.config.js
@@ -79,6 +79,8 @@ export default defineConfig(({ command }) => {
/^\[data-pc-/,
// Комбинированные селекторы с .p- классами
/\.p-.*\[data-/,
+ // Uppy — рендерит overlay/modal в DOM, стили без префикса
+ /^\.uppy-/,
],
// Трансформация селектора
transform: function (prefix, selector) {