Improve menu images, mobile links and sprite caching
CI / PHP lint (8.1) (push) Successful in 2m9s
CI / PHP lint (8.5) (push) Successful in 1m35s
CI / Coding standards (push) Successful in 1m7s

This commit is contained in:
Tiamak
2026-07-22 23:18:05 +02:00
parent 7236d17ead
commit 74618146c9
8 changed files with 190 additions and 47 deletions
+59 -11
View File
@@ -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[] = '<link rel="preload" href="' . htmlspecialchars($spriteImageUrl, ENT_QUOTES, 'UTF-8') . '" as="image" type="image/webp">';
}
@@ -406,16 +418,23 @@ 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'),
]);
foreach ($validTokens as $validToken) {
if (hash_equals((string) $validToken, $token)) {
return;
}
}
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'),
]));
}
}
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<string, mixed> $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, '/');
+28 -12
View File
@@ -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,14 +146,16 @@ 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($cssPath)) {
unlink($cssPath);
}
foreach (glob($imageOutputDir . '/menu-sprite*.webp') ?: [] as $spritePath) {
if (is_file($spritePath)) {
unlink($spritePath);
}
if (is_file($cssPath)) {
unlink($cssPath);
}
}
+13
View File
@@ -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;
+25
View File
@@ -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);
+51 -10
View File
@@ -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 = '<i class="icon-trash"></i><span>' + escapeHtml(t('removeImage', 'Remove image')) + '</span>';
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');
});
}
-8
View File
@@ -219,14 +219,6 @@
return;
}
item.addEventListener('mouseenter', function () {
if (!isDesktop()) {
return;
}
activateSubmenuPane(item);
});
bindDesktopClickIntent(link, function () {
activateSubmenuPane(item);
});
+1
View File
@@ -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}",
+7
View File
@@ -174,7 +174,14 @@
<button type="button" class="adv-megamenu__mobile-back js-adv-back">
&lt; {l s='Wstecz' d='Modules.Advancedmegamenu.Shop'}
</button>
{if $node.url && $node.url != '#'}
<a href="{$node.url|escape:'htmlall':'UTF-8'}" class="adv-megamenu__mobile-title-link" {if $node.new_window}target="_blank" rel="noopener"{/if}>
<span>{$node.title|escape:'htmlall':'UTF-8'}</span>
<span class="material-icons" aria-hidden="true">arrow_forward</span>
</a>
{else}
<span class="adv-megamenu__mobile-title">{$node.title|escape:'htmlall':'UTF-8'}</span>
{/if}
</div>
<ul class="adv-megamenu__mobile-list">
{foreach from=$node.category_branch item=branch}