From 74618146c9bb6abb7c5ab04913e2371140dec435 Mon Sep 17 00:00:00 2001 From: Tiamak Date: Wed, 22 Jul 2026 23:18:05 +0200 Subject: [PATCH] Improve menu images, mobile links and sprite caching --- advancedmegamenu.php | 78 +++++++++++++++++++++++------ src/Classes/SpriteGenerator.php | 42 +++++++++++----- views/css/admin.css | 13 +++++ views/css/megamenu.css | 25 +++++++++ views/js/admin.js | 61 ++++++++++++++++++---- views/js/megamenu.js | 8 --- views/templates/admin/configure.tpl | 1 + views/templates/hook/megamenu.tpl | 9 +++- 8 files changed, 190 insertions(+), 47 deletions(-) diff --git a/advancedmegamenu.php b/advancedmegamenu.php index b4817dc..a0e0239 100644 --- a/advancedmegamenu.php +++ b/advancedmegamenu.php @@ -144,7 +144,8 @@ class AdvancedMegaMenu extends Module implements WidgetInterface $output = $this->postProcess(); $this->context->controller->addCSS($this->_path . 'views/css/admin.css'); - $this->context->controller->addJS($this->_path . 'views/js/admin.js'); + $adminJsPath = $this->getLocalPath() . 'views/js/admin.js'; + $this->context->controller->addJS($this->_path . 'views/js/admin.js?v=' . (is_file($adminJsPath) ? (string) filemtime($adminJsPath) : (string) time())); $languages = $this->context->controller->getLanguages(); $repository = new MenuRepository(Db::getInstance(), $this); @@ -155,7 +156,7 @@ class AdvancedMegaMenu extends Module implements WidgetInterface 'advmegamenu_default_lang' => (int) Configuration::get('PS_LANG_DEFAULT'), 'advmegamenu_tree' => $tree, 'advmegamenu_ajax_url' => $this->getAdminAjaxUrl(), - 'advmegamenu_admin_token' => Tools::getAdminTokenLite('AdminModules'), + 'advmegamenu_admin_token' => $this->getAjaxToken(), ]); return $output . $this->display(__FILE__, 'views/templates/admin/configure.tpl'); @@ -197,31 +198,42 @@ class AdvancedMegaMenu extends Module implements WidgetInterface return ''; } + $menuCssFile = $this->getLocalPath() . 'views/css/megamenu.css'; + $menuJsFile = $this->getLocalPath() . 'views/js/megamenu.js'; + $controller->registerStylesheet( 'module-advancedmegamenu-front', $this->_path . 'views/css/megamenu.css', - ['media' => 'all', 'priority' => 150] + [ + 'media' => 'all', + 'priority' => 150, + 'version' => is_file($menuCssFile) ? (string) filemtime($menuCssFile) : $this->version, + ] ); $controller->registerJavascript( 'module-advancedmegamenu-front', $this->_path . 'views/js/megamenu.js', - ['position' => 'bottom', 'priority' => 150] + [ + 'position' => 'bottom', + 'priority' => 150, + 'version' => is_file($menuJsFile) ? (string) filemtime($menuJsFile) : $this->version, + ] ); $spriteCssFile = $this->getLocalPath() . 'views/css/generated/menu-sprite.css'; if (is_file($spriteCssFile)) { $spriteCssUrl = $this->_path . 'views/css/generated/menu-sprite.css'; - $spriteImageFile = $this->getLocalPath() . 'views/img/generated/menu-sprite.webp'; + $spriteImageFile = $this->getCurrentSpriteImageFile($spriteCssFile); $controller->registerStylesheet( 'module-advancedmegamenu-sprite', $spriteCssUrl, - ['media' => 'all', 'priority' => 151] + ['media' => 'all', 'priority' => 151, 'version' => (string) filemtime($spriteCssFile)] ); $preloadLinks = []; - if (is_file($spriteImageFile)) { - $spriteImageUrl = $this->_path . 'views/img/generated/menu-sprite.webp?v=' . (string) filemtime($spriteImageFile); + if ($spriteImageFile && is_file($spriteImageFile)) { + $spriteImageUrl = $this->_path . 'views/img/generated/' . basename($spriteImageFile); $preloadLinks[] = ''; } @@ -406,15 +418,22 @@ class AdvancedMegaMenu extends Module implements WidgetInterface private function assertAdminToken(): void { $token = (string) Tools::getValue('token'); - $expectedToken = Tools::getAdminTokenLite('AdminModules'); + $validTokens = array_filter([ + $this->getAjaxToken(), + Tools::getAdminTokenLite('AdminModules'), + ]); - if (!hash_equals($expectedToken, $token)) { - header('Content-Type: application/json', true, 403); - exit(json_encode([ - 'error' => true, - 'message' => $this->trans('Invalid admin token.', [], 'Modules.Advancedmegamenu.Admin'), - ])); + foreach ($validTokens as $validToken) { + if (hash_equals((string) $validToken, $token)) { + return; + } } + + header('Content-Type: application/json', true, 403); + exit(json_encode([ + 'error' => true, + 'message' => $this->trans('Invalid admin token.', [], 'Modules.Advancedmegamenu.Admin'), + ])); } private function ajaxSearchProducts(): void @@ -615,6 +634,28 @@ class AdvancedMegaMenu extends Module implements WidgetInterface }, $icons)); } + private function getCurrentSpriteImageFile(string $spriteCssFile): string + { + $css = (string) @file_get_contents($spriteCssFile); + if (preg_match('#img/generated/([^")]+\.webp)#', $css, $matches)) { + $spriteImageFile = $this->getLocalPath() . 'views/img/generated/' . basename($matches[1]); + if (is_file($spriteImageFile)) { + return $spriteImageFile; + } + } + + $spriteFiles = glob($this->getLocalPath() . 'views/img/generated/menu-sprite*.webp') ?: []; + if (empty($spriteFiles)) { + return ''; + } + + usort($spriteFiles, static function (string $left, string $right): int { + return filemtime($right) <=> filemtime($left); + }); + + return (string) $spriteFiles[0]; + } + /** * @param array $node * @@ -970,6 +1011,13 @@ class AdvancedMegaMenu extends Module implements WidgetInterface ]); } + private function getAjaxToken(): string + { + $employeeId = isset($this->context->employee) ? (int) $this->context->employee->id : 0; + + return Tools::hash($this->name . '|ajax|' . $employeeId); + } + private function buildModuleImageUrl(string $path): string { return rtrim($this->getPathUri(), '/') . '/views/img/' . ltrim($path, '/'); diff --git a/src/Classes/SpriteGenerator.php b/src/Classes/SpriteGenerator.php index e81c951..5a0e6ec 100644 --- a/src/Classes/SpriteGenerator.php +++ b/src/Classes/SpriteGenerator.php @@ -22,7 +22,6 @@ class SpriteGenerator { $imageOutputDir = $this->moduleDir . '/views/img/generated'; $cssOutputDir = $this->moduleDir . '/views/css/generated'; - $spritePath = $imageOutputDir . '/menu-sprite.webp'; $cssPath = $cssOutputDir . '/menu-sprite.css'; if (!is_dir($imageOutputDir)) { @@ -34,7 +33,7 @@ class SpriteGenerator } if (empty($icons)) { - $this->cleanup($spritePath, $cssPath); + $this->cleanup($imageOutputDir, $cssPath); return; } @@ -77,7 +76,7 @@ class SpriteGenerator } if (empty($images) || $totalWidth === 0 || $maxHeight === 0) { - $this->cleanup($spritePath, $cssPath); + $this->cleanup($imageOutputDir, $cssPath); return; } @@ -101,22 +100,37 @@ class SpriteGenerator ); } - $spriteWritten = imagewebp($sprite, $spritePath, 85); + $tmpSpritePath = $imageOutputDir . '/menu-sprite.tmp.webp'; + $spriteWritten = imagewebp($sprite, $tmpSpritePath, 85); imagedestroy($sprite); - if (!$spriteWritten || !is_file($spritePath)) { + if (!$spriteWritten || !is_file($tmpSpritePath)) { foreach ($images as $iconImage) { imagedestroy($iconImage['resource']); } - $this->cleanup($spritePath, $cssPath); + $this->cleanup($imageOutputDir, $cssPath); + + return; + } + + $hash = substr((string) sha1_file($tmpSpritePath), 0, 16); + $spriteFilename = 'menu-sprite-' . $hash . '.webp'; + $spritePath = $imageOutputDir . '/' . $spriteFilename; + + if (!rename($tmpSpritePath, $spritePath)) { + @unlink($tmpSpritePath); + foreach ($images as $iconImage) { + imagedestroy($iconImage['resource']); + } + + $this->cleanup($imageOutputDir, $cssPath); return; } - $version = is_file($spritePath) ? (string) filemtime($spritePath) : (string) time(); $css = []; - $css[] = '.adv-megamenu__icon{display:inline-block;background-repeat:no-repeat;background-image:url("../../img/generated/menu-sprite.webp?v=' . $version . '");}'; + $css[] = '.adv-megamenu__icon{display:inline-block;background-repeat:no-repeat;background-image:url("../../img/generated/' . $spriteFilename . '");}'; foreach ($images as $iconImage) { $css[] = sprintf( @@ -132,15 +146,17 @@ class SpriteGenerator file_put_contents($cssPath, implode(PHP_EOL, $css) . PHP_EOL); } - private function cleanup(string $spritePath, string $cssPath): void + private function cleanup(string $imageOutputDir, string $cssPath): void { - if (is_file($spritePath)) { - unlink($spritePath); - } - if (is_file($cssPath)) { unlink($cssPath); } + + foreach (glob($imageOutputDir . '/menu-sprite*.webp') ?: [] as $spritePath) { + if (is_file($spritePath)) { + unlink($spritePath); + } + } } /** diff --git a/views/css/admin.css b/views/css/admin.css index e9eac7c..41b8510 100644 --- a/views/css/admin.css +++ b/views/css/admin.css @@ -211,6 +211,19 @@ border: 1px solid #edf1f4; } +.adv-menu-image-preview-wrap { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; +} + +.adv-menu-image-remove { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + .adv-menu-admin-node__collections { display: grid; gap: 1rem; diff --git a/views/css/megamenu.css b/views/css/megamenu.css index cb81be4..351396e 100644 --- a/views/css/megamenu.css +++ b/views/css/megamenu.css @@ -569,6 +569,31 @@ font-weight: 700; } +.adv-megamenu .adv-megamenu__mobile-title-link, +.adv-megamenu .adv-megamenu__mobile-title-link:link, +.adv-megamenu .adv-megamenu__mobile-title-link:visited { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 0.25rem; + min-width: 0; + color: #8a3c1f; + font-weight: 600; + line-height: 1.25; + text-align: right; + text-decoration: none; +} + +.adv-megamenu__mobile-title-link .material-icons { + flex: 0 0 auto; + font-size: 1.125rem; +} + +.adv-megamenu .adv-megamenu__mobile-title-link:hover, +.adv-megamenu .adv-megamenu__mobile-title-link:focus-visible { + color: #8a3c1f; +} + .adv-megamenu__mobile-viewport { position: relative; height: calc(100% - 56px); diff --git a/views/js/admin.js b/views/js/admin.js index c2c353c..c9f5f48 100644 --- a/views/js/admin.js +++ b/views/js/admin.js @@ -458,7 +458,12 @@ var rootHint = nodeModal.querySelector('.js-node-root-hint'); var imageGroup = nodeModal.querySelector('.js-node-image-group'); var previewWrap = nodeModal.querySelector('.js-node-icon-preview'); - renderImagePreview(previewWrap, depth > 0 ? node.icon_url : ''); + renderImagePreview(previewWrap, depth > 0 ? node.icon_url : '', function () { + node.icon_path = ''; + node.icon_url = ''; + renderImagePreview(previewWrap, ''); + serializeTree(); + }); rootHint.style.display = depth === 0 ? 'block' : 'none'; rootHint.textContent = t('rootOnlyHint', 'For first-level items only title and custom link are edited here.'); @@ -473,7 +478,12 @@ uploadImage(event.target.files[0], 'menu_icon', function (response) { node.icon_path = response.path; node.icon_url = response.url; - renderImagePreview(previewWrap, response.url); + renderImagePreview(previewWrap, response.url, function () { + node.icon_path = ''; + node.icon_url = ''; + renderImagePreview(previewWrap, ''); + serializeTree(); + }); serializeTree(); }, { menuDepth: depth, @@ -572,7 +582,13 @@ layoutModal.querySelector('.js-layout-type').value = layout.block_type || 'promo'; layoutModal.querySelector('.js-layout-title').checked = !!layout.show_title; layoutModal.querySelector('.js-layout-bg').value = layout.background_color || ''; - renderImagePreview(layoutModal.querySelector('.js-layout-preview'), layout.custom_image_url); + var layoutPreviewWrap = layoutModal.querySelector('.js-layout-preview'); + renderImagePreview(layoutPreviewWrap, layout.custom_image_url, function () { + layout.custom_image = ''; + layout.custom_image_url = ''; + renderImagePreview(layoutPreviewWrap, ''); + serializeTree(); + }); renderProducts(layout.products, layoutModal.querySelector('.js-layout-products')); layoutModal.querySelector('.js-layout-product-results').innerHTML = ''; layoutModal.querySelector('.js-layout-product-search').value = ''; @@ -583,7 +599,12 @@ uploadImage(event.target.files[0], 'layout_image', function (response) { layout.custom_image = response.path; layout.custom_image_url = response.url; - renderImagePreview(layoutModal.querySelector('.js-layout-preview'), response.url); + renderImagePreview(layoutPreviewWrap, response.url, function () { + layout.custom_image = ''; + layout.custom_image_url = ''; + renderImagePreview(layoutPreviewWrap, ''); + serializeTree(); + }); serializeTree(); }); }; @@ -678,16 +699,30 @@ showModal(confirmModal); } - function renderImagePreview(wrap, url) { + function renderImagePreview(wrap, url, onRemove) { wrap.innerHTML = ''; if (!url) { return; } + var preview = document.createElement('div'); + preview.className = 'adv-menu-image-preview-wrap'; + var image = document.createElement('img'); image.src = url; image.className = 'adv-menu-image-preview'; - wrap.appendChild(image); + preview.appendChild(image); + + if (typeof onRemove === 'function') { + var removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'btn btn-default btn-sm adv-menu-image-remove'; + removeButton.innerHTML = '' + escapeHtml(t('removeImage', 'Remove image')) + ''; + removeButton.addEventListener('click', onRemove); + preview.appendChild(removeButton); + } + + wrap.appendChild(preview); } function getNodeTitle(node) { @@ -727,9 +762,6 @@ var uploadOptions = options || {}; var formData = new FormData(); - formData.append('ajax', '1'); - formData.append('action', 'uploadImage'); - formData.append('token', token); formData.append('preset', preset || 'default'); if (typeof uploadOptions.menuDepth !== 'undefined') { formData.append('menu_depth', String(uploadOptions.menuDepth)); @@ -739,17 +771,26 @@ } formData.append('image', file); - fetch(ajaxUrl, { + fetch(ajaxUrl + '&ajax=1&action=uploadImage&token=' + encodeURIComponent(token), { method: 'POST', body: formData }) .then(function (response) { + if (!response.ok) { + throw new Error('Upload failed: HTTP ' + response.status); + } + return response.json(); }) .then(function (payload) { if (payload && !payload.error) { callback(payload); + return; } + throw new Error(payload && payload.message ? payload.message : 'Upload failed'); + }) + .catch(function (error) { + window.alert(error.message || 'Upload failed'); }); } diff --git a/views/js/megamenu.js b/views/js/megamenu.js index 68d0412..a71a0cc 100644 --- a/views/js/megamenu.js +++ b/views/js/megamenu.js @@ -219,14 +219,6 @@ return; } - item.addEventListener('mouseenter', function () { - if (!isDesktop()) { - return; - } - - activateSubmenuPane(item); - }); - bindDesktopClickIntent(link, function () { activateSubmenuPane(item); }); diff --git a/views/templates/admin/configure.tpl b/views/templates/admin/configure.tpl index f83af70..dba8130 100644 --- a/views/templates/admin/configure.tpl +++ b/views/templates/admin/configure.tpl @@ -43,6 +43,7 @@ "description": "{l s='Description' d='Modules.Advancedmegamenu.Admin' js=1}", "customLinkLabel": "{l s='Custom link' d='Modules.Advancedmegamenu.Admin' js=1}", "iconUpload": "{l s='Item image/icon' d='Modules.Advancedmegamenu.Admin' js=1}", + "removeImage": "{l s='Remove image' d='Modules.Advancedmegamenu.Admin' js=1}", "rootOnlyHint": "{l s='For first-level items only title and custom link are edited here.' d='Modules.Advancedmegamenu.Admin' js=1}", "width": "{l s='Width' d='Modules.Advancedmegamenu.Admin' js=1}", "blockType": "{l s='Block type' d='Modules.Advancedmegamenu.Admin' js=1}", diff --git a/views/templates/hook/megamenu.tpl b/views/templates/hook/megamenu.tpl index 36241f0..03cdcdc 100644 --- a/views/templates/hook/megamenu.tpl +++ b/views/templates/hook/megamenu.tpl @@ -174,7 +174,14 @@ - {$node.title|escape:'htmlall':'UTF-8'} + {if $node.url && $node.url != '#'} + + {$node.title|escape:'htmlall':'UTF-8'} + + + {else} + {$node.title|escape:'htmlall':'UTF-8'} + {/if}