diff --git a/htdocs/class/theme.php b/htdocs/class/theme.php
index abd63c5d..955adc15 100644
--- a/htdocs/class/theme.php
+++ b/htdocs/class/theme.php
@@ -293,6 +293,7 @@ public function xoInit($options = [])
$this->template->assign('xoTheme', $this);
$GLOBALS['xoTheme'] = $this;
$GLOBALS['xoopsTpl'] = $this->template;
+
$tempPath = str_replace('\\', '/', realpath(XOOPS_ROOT_PATH) . '/');
$tempName = str_replace('\\', '/', realpath($_SERVER['SCRIPT_FILENAME']));
$xoops_page = str_replace($tempPath, '', $tempName);
@@ -402,9 +403,349 @@ public function xoInit($options = [])
}
}
+ // load menu categories and their nested items so themes can render navigation
+ $this->template->assign('xoMenuCategories', $this->loadMenus());
+
+
return true;
}
+ /**
+ * Load active menu categories with their nested items.
+ *
+ * @return array
+ */
+ protected function loadMenus()
+ {
+ $helper = Xmf\Module\Helper::getHelper('system');
+ if (!(int)$helper->getConfig('active_menus', 0)) {
+ return [];
+ }
+ $cacheTtl = max(0, (int)$helper->getConfig('menus_cache_ttl', 300));
+
+ // automatically includes the shared style sheet for
+ // multilingual menus; it is located in the system module and
+ // is available for all themes.
+ $css = 'multilevelmenu.css';
+ $path = XOOPS_ROOT_PATH . '/modules/system/css/' . $css;
+ if (file_exists($path)) {
+ $this->addStylesheet(XOOPS_URL . '/modules/system/css/' . $css);
+ }
+
+ // include shared javascript helpers for multilevel menus if available
+ $js = 'multilevelmenu.js';
+ $jsPath = XOOPS_ROOT_PATH . '/modules/system/js/' . $js;
+ if (file_exists($jsPath)) {
+ $this->addScript(XOOPS_URL . '/modules/system/js/' . $js);
+ }
+
+ $groups = is_object($GLOBALS['xoopsUser']) ? $GLOBALS['xoopsUser']->getGroups() : [XOOPS_GROUP_ANONYMOUS];
+ $cacheKey = self::getMenusCacheKey($GLOBALS['xoopsConfig']['language'], $groups);
+ if ($cacheTtl > 0) {
+ xoops_load('xoopscache');
+ $cachedMenus = XoopsCache::read($cacheKey);
+ if (false !== $cachedMenus && is_array($cachedMenus)) {
+ return $this->renderMenuAffixesRecursive($cachedMenus);
+ }
+ }
+
+ $menus = [];
+ $menuscategoryHandler = xoops_getHandler('menuscategory');
+ if (!is_object($menuscategoryHandler) && class_exists('XoopsMenusCategoryHandler')) {
+ $menuscategoryHandler = new XoopsMenusCategoryHandler($GLOBALS['xoopsDB']);
+ }
+
+ // Check that the handler is valid and that the table exists (mainly to avoid triggering a fatal error before the system module is migrated when updating from a version 2.5.X of XOOPS)
+ $category_arr = [];
+ $viewPermissionItem = [];
+
+ if (is_object($menuscategoryHandler)) {
+ try {
+ $viewPermissionCat = [];
+ $moduleHandler = $helper->getModule();
+ $gpermHandler = xoops_getHandler('groupperm');
+ $viewPermissionCat = $gpermHandler->getItemIds('menus_category_view', $groups, $moduleHandler->getVar('mid'));
+ $viewPermissionItem = $gpermHandler->getItemIds('menus_items_view', $groups, $moduleHandler->getVar('mid'));
+ if (!empty($viewPermissionCat)) {
+ $criteria = new CriteriaCompo();
+ $criteria->add(new Criteria('category_active', 1));
+ $criteria->add(new Criteria('category_id', '(' . implode(',', $viewPermissionCat) . ')', 'IN'));
+ $criteria->setSort('category_position');
+ $criteria->setOrder('ASC');
+ $category_arr = $menuscategoryHandler->getAll($criteria);
+ } else {
+ $category_arr = [];
+ }
+ } catch (Throwable $e) {
+ $category_arr = [];
+ $viewPermissionItem = [];
+ }
+ }
+ if (!empty($category_arr)) {
+ $menusitemsHandler = xoops_getHandler('menusitems');
+ if (!is_object($menusitemsHandler) && class_exists('XoopsMenusItemsHandler')) {
+ $menusitemsHandler = new XoopsMenusItemsHandler($GLOBALS['xoopsDB']);
+ }
+ include_once $GLOBALS['xoops']->path('class/tree.php');
+ foreach ($category_arr as $cat) {
+ try {
+ $cid = $cat->getVar('category_id');
+ if (!empty($viewPermissionItem)) {
+ $crit = new CriteriaCompo();
+ $crit->add(new Criteria('items_id', '(' . implode(',', $viewPermissionItem) . ')', 'IN'));
+ $crit->add(new Criteria('items_cid', $cid));
+ $crit->add(new Criteria('items_active', 1));
+ $crit->setSort('items_position, items_title');
+ $crit->setOrder('ASC');
+ $items_arr = $menusitemsHandler->getAll($crit);
+ $myTree = new XoopsObjectTree($items_arr, 'items_id', 'items_pid');
+ // recursive closure to build nested structure
+ $buildNested = function ($treeObj, $parentId = 0) use (&$buildNested) {
+ $nodes = [];
+ $children = $treeObj->getFirstChild($parentId);
+ foreach ($children as $child) {
+ $cid2 = $child->getVar('items_id');
+ $childNodes = $buildNested($treeObj, $cid2);
+ $entry = [
+ 'id' => $cid2,
+ 'title' => $child->getResolvedTitle(),
+ 'prefix' => $child->getVar('items_prefix'),
+ 'suffix' => $child->getVar('items_suffix'),
+ 'url' => empty($childNodes) ? self::normalizeMenuUrl($child->getVar('items_url')) : '',
+ 'target' => ($child->getVar('items_target') == 1) ? '_blank' : '_self',
+ 'active' => $child->getVar('items_active'),
+ 'children' => $childNodes,
+ ];
+ $nodes[] = $entry;
+ }
+ return $nodes;
+ };
+ $item_list = $buildNested($myTree, 0);
+ } else {
+ $item_list = [];
+ }
+ $menus[] = [
+ 'category_id' => $cid,
+ 'category_title' => $cat->getResolvedTitle(),
+ 'category_prefix' => $cat->getVar('category_prefix'),
+ 'category_suffix' => $cat->getVar('category_suffix'),
+ 'category_url' => empty($item_list) ? self::normalizeMenuUrl($cat->getVar('category_url')) : '',
+ 'category_target' => ($cat->getVar('category_target') == 1) ? '_blank' : '_self',
+ 'items' => $item_list,
+ ];
+ } catch (Throwable $e) {
+ // Silencieusement ignorer les erreurs de catégories particulières
+ continue;
+ }
+ }
+ }
+
+ if ($cacheTtl > 0) {
+ XoopsCache::write($cacheKey, $menus, $cacheTtl);
+ self::registerMenusCacheKey($cacheKey);
+ }
+
+ return $this->renderMenuAffixesRecursive($menus);
+ }
+
+ /**
+ * Return the cache index key used to track menu cache entries.
+ *
+ * @return string
+ */
+ public static function getMenusCacheIndexKey()
+ {
+ return 'system_menus_cache_keys';
+ }
+
+ /**
+ * Build a cache key for menus using language and user groups.
+ *
+ * @param string $language
+ * @param array $groups
+ * @return string
+ */
+ public static function getMenusCacheKey($language, array $groups)
+ {
+ sort($groups);
+
+ return 'system_menus_' . md5($language . '|' . implode('-', $groups));
+ }
+
+ /**
+ * Track a cache key so admin updates can invalidate all menu variants.
+ *
+ * @param string $cacheKey
+ * @return void
+ */
+ protected static function registerMenusCacheKey($cacheKey)
+ {
+ $indexKey = self::getMenusCacheIndexKey();
+ $cacheKeys = XoopsCache::read($indexKey);
+ if (!is_array($cacheKeys)) {
+ $cacheKeys = [];
+ }
+ if (!in_array($cacheKey, $cacheKeys, true)) {
+ $cacheKeys[] = $cacheKey;
+ XoopsCache::write($indexKey, $cacheKeys, 31536000);
+ }
+ }
+
+ /**
+ * Invalidate all tracked menu cache variants.
+ *
+ * @return void
+ */
+ public static function invalidateMenusCache()
+ {
+ xoops_load('xoopscache');
+
+ $indexKey = self::getMenusCacheIndexKey();
+ $cacheKeys = XoopsCache::read($indexKey);
+ if (is_array($cacheKeys)) {
+ foreach ($cacheKeys as $cacheKey) {
+ XoopsCache::delete($cacheKey);
+ }
+ }
+ XoopsCache::delete($indexKey);
+ }
+
+ /**
+ * Render dynamic affixes after reading the menu tree from cache.
+ *
+ * @param array $menus
+ * @return array
+ */
+ protected function renderMenuAffixesRecursive(array $menus)
+ {
+ foreach ($menus as &$menu) {
+ if (isset($menu['url'])) {
+ $menu['url'] = self::normalizeMenuUrl($menu['url']);
+ }
+ if (isset($menu['category_url'])) {
+ $menu['category_url'] = self::normalizeMenuUrl($menu['category_url']);
+ }
+ if (isset($menu['category_prefix'])) {
+ $menu['category_prefix'] = $this->renderMenuAffix($menu['category_prefix']);
+ }
+ if (isset($menu['category_suffix'])) {
+ $menu['category_suffix'] = $this->renderMenuAffix($menu['category_suffix']);
+ }
+ if (isset($menu['prefix'])) {
+ $menu['prefix'] = $this->renderMenuAffix($menu['prefix']);
+ }
+ if (isset($menu['suffix'])) {
+ $menu['suffix'] = $this->renderMenuAffix($menu['suffix']);
+ }
+ if (!empty($menu['children']) && is_array($menu['children'])) {
+ $menu['children'] = $this->renderMenuAffixesRecursive($menu['children']);
+ }
+ if (!empty($menu['items']) && is_array($menu['items'])) {
+ $menu['items'] = $this->renderMenuAffixesRecursive($menu['items']);
+ }
+ }
+ unset($menu);
+
+ return $menus;
+ }
+
+ /**
+ * Normalize menu URL values by prefixing XOOPS_URL on relative paths.
+ *
+ * @param string $url
+ * @return string
+ */
+ public static function normalizeMenuUrl($url)
+ {
+ $url = trim((string)$url);
+ if ('' === $url) {
+ return '';
+ }
+ if (0 === strncmp($url, XOOPS_URL, strlen(XOOPS_URL))) {
+ return $url;
+ }
+ if (preg_match('~^(?:[a-z][a-z0-9+\-.]*:|//|/|#|\?)~i', $url)) {
+ return $url;
+ }
+
+ return XOOPS_URL . '/' . ltrim($url, '/');
+ }
+
+ /**
+ * Render a menu prefix/suffix that contains the xoInboxCount Smarty tag.
+ *
+ * @param string $value
+ * @return string
+ */
+ protected function renderMenuAffix($value)
+ {
+ $value = (string)$value;
+ if ('' === $value) {
+ return $value;
+ }
+
+ // Values coming from DB can be entity-encoded depending on getVar mode.
+ $decodedSuffix = htmlspecialchars_decode($value, ENT_QUOTES | ENT_HTML5);
+ if (false === stripos($decodedSuffix, 'xoInboxCount')) {
+ return $decodedSuffix;
+ }
+
+ try {
+ $unread = $this->getInboxUnreadCount();
+ $replacement = null === $unread ? '' : (string)$unread;
+ $rendered = preg_replace('/<{\s*xoInboxCount(?:\s+[^}]*)?\s*}>/i', $replacement, $decodedSuffix);
+
+ return null !== $rendered ? $rendered : $decodedSuffix;
+ } catch (Throwable $e) {
+ return $decodedSuffix;
+ }
+ }
+
+ /**
+ * Get unread private message count for current user.
+ *
+ * Mirrors the cache behavior of smarty_function_xoInboxCount.
+ *
+ * @return int|null
+ */
+ protected function getInboxUnreadCount()
+ {
+ global $xoopsUser;
+
+ if (!isset($xoopsUser) || !is_object($xoopsUser)) {
+ return null;
+ }
+
+ $freshRead = isset($GLOBALS['xoInboxCountFresh']);
+ $pmScripts = ['pmlite', 'readpmsg', 'viewpmsg'];
+ if (in_array(basename($_SERVER['SCRIPT_FILENAME'], '.php'), $pmScripts)) {
+ if (!$freshRead) {
+ unset($_SESSION['xoops_inbox_count'], $_SESSION['xoops_inbox_total'], $_SESSION['xoops_inbox_count_expire']);
+ $GLOBALS['xoInboxCountFresh'] = true;
+ }
+ }
+
+ $time = time();
+ if (isset($_SESSION['xoops_inbox_count']) && (isset($_SESSION['xoops_inbox_count_expire']) && $_SESSION['xoops_inbox_count_expire'] > $time)) {
+ return (int)$_SESSION['xoops_inbox_count'];
+ }
+
+ /** @var \XoopsPrivmessageHandler $pm_handler */
+ $pm_handler = xoops_getHandler('privmessage');
+
+ $xoopsPreload = XoopsPreload::getInstance();
+ $xoopsPreload->triggerEvent('core.class.smarty.xoops_plugins.xoinboxcount', [$pm_handler]);
+
+ $criteria = new CriteriaCompo(new Criteria('to_userid', $xoopsUser->getVar('uid')));
+ $_SESSION['xoops_inbox_total'] = $pm_handler->getCount($criteria);
+
+ $criteria->add(new Criteria('read_msg', 0));
+ $_SESSION['xoops_inbox_count'] = $pm_handler->getCount($criteria);
+ $_SESSION['xoops_inbox_count_expire'] = $time + 60;
+
+ return (int)$_SESSION['xoops_inbox_count'];
+ }
+
/**
* Generate cache id based on extra information of language and user groups
*
diff --git a/htdocs/class/tree.php b/htdocs/class/tree.php
index cb2bd49d..3ac025f1 100644
--- a/htdocs/class/tree.php
+++ b/htdocs/class/tree.php
@@ -66,6 +66,9 @@ protected function initialize()
$key1 = $this->objects[$i]->getVar($this->myId);
$this->tree[$key1]['obj'] = $this->objects[$i];
$key2 = $this->objects[$i]->getVar($this->parentId);
+ if (null === $key2 || '' === $key2) {
+ $key2 = 0;
+ }
$this->tree[$key1]['parent'] = $key2;
$this->tree[$key2]['child'][] = $key1;
if (isset($this->rootId)) {
diff --git a/htdocs/kernel/menuscategory.php b/htdocs/kernel/menuscategory.php
new file mode 100644
index 00000000..390c110c
--- /dev/null
+++ b/htdocs/kernel/menuscategory.php
@@ -0,0 +1,243 @@
+initVar('category_id', XOBJ_DTYPE_INT, null, false);
+ $this->initVar('category_title', XOBJ_DTYPE_TXTBOX, null);
+ $this->initVar('category_prefix', XOBJ_DTYPE_TXTAREA);
+ $this->initVar('category_suffix', XOBJ_DTYPE_TXTAREA);
+ $this->initVar('category_url', XOBJ_DTYPE_TXTBOX, null);
+ $this->initVar('category_target', XOBJ_DTYPE_INT, 0);
+ $this->initVar('category_position', XOBJ_DTYPE_INT, null, false);
+ $this->initVar('category_protected', XOBJ_DTYPE_INT, 0);
+ $this->initVar('category_active', XOBJ_DTYPE_INT, 1);
+ // use html
+ $this->initVar('dohtml', XOBJ_DTYPE_INT, 1, false);
+ // Load language file for menu items
+ $language = $GLOBALS['xoopsConfig']['language'] ?? 'english';
+ $fileinc = XOOPS_ROOT_PATH . "/modules/system/language/{$language}/menus/menus.php";
+ if (!isset(self::$languageFilesIncluded[$fileinc])) {
+ if (file_exists($fileinc)) {
+ include_once $fileinc;
+ self::$languageFilesIncluded[$fileinc] = true;
+ }
+ }
+ // Load language file for admin menus
+ $fileinc_admin = XOOPS_ROOT_PATH . "/modules/system/language/{$language}/admin/menus.php";
+ if (!isset(self::$languageFilesIncluded[$fileinc_admin])) {
+ if (file_exists($fileinc_admin)) {
+ include_once $fileinc_admin;
+ self::$languageFilesIncluded[$fileinc_admin] = true;
+ }
+ }
+ }
+ /**
+ * Retrieve the resolved category_title for display via getResolvedTitle().
+ *
+ * If the stored category_title is a constant name, getResolvedTitle()
+ * resolves and returns its value. Otherwise it returns category_title as-is.
+ * Used for front-end rendering.
+ *
+ * Example:
+ * - If category_title = "HOME_LABEL" and HOME_LABEL = "Accueil"
+ * returns "Accueil"
+ * - If category_title = "Custom Text"
+ * returns "Custom Text"
+ *
+ * @return string The resolved title value
+ */
+ public function getResolvedTitle()
+ {
+ $title = $this->getVar('category_title');
+ return defined($title) ? constant($title) : $title;
+ }
+
+ /**
+ * Retrieve category_title for the administration interface via getAdminTitle().
+ *
+ * If the stored category_title is a constant name, getAdminTitle() returns
+ * the resolved constant value plus the constant name in parentheses,
+ * for example: constant('HOME_LABEL') . ' (HOME_LABEL)'.
+ * Otherwise getAdminTitle() returns category_title as-is.
+ *
+ * Example:
+ * - If category_title = "HOME_LABEL" and HOME_LABEL = "Accueil"
+ * returns "Accueil (HOME_LABEL)"
+ * - If category_title = "Custom Text"
+ * returns "Custom Text"
+ *
+ * @return string The resolved title with optional constant reference
+ */
+ public function getAdminTitle()
+ {
+ $title = $this->getVar('category_title');
+ if (defined($title)) {
+ return constant($title) . ' (' . $title . ')';
+ } else {
+ return $title;
+ }
+ }
+
+ /**
+ * @return mixed
+ */
+ public function get_new_enreg()
+ {
+ global $xoopsDB;
+ $new_enreg = $xoopsDB->getInsertId();
+
+ return $new_enreg;
+ }
+
+ public function getFormCat($action = false)
+ {
+ if ($action === false) {
+ $action = $_SERVER['REQUEST_URI'];
+ }
+ include_once XOOPS_ROOT_PATH . '/class/xoopsformloader.php';
+ //include __DIR__ . '/../include/common.php';
+
+ //form title
+ $title = $this->isNew() ? sprintf(_AM_SYSTEM_MENUS_ADDCAT) : sprintf(_AM_SYSTEM_MENUS_EDITCAT);
+
+ $form = new XoopsThemeForm($title, 'form', $action, 'post', true);
+ $form->setExtra('enctype="multipart/form-data"');
+
+ $isProtected = false;
+ if (!$this->isNew()) {
+ $form->addElement(new XoopsFormHidden('category_id', $this->getVar('category_id')));
+ $position = $this->getVar('category_position');
+ $active = $this->getVar('category_active');
+ $isProtected = (int)$this->getVar('category_protected') === 1;
+ } else {
+ $position = 0;
+ $active = 1;
+ }
+
+ // title
+ $title = new XoopsFormText(_AM_SYSTEM_MENUS_TITLECAT, 'category_title', 50, 255, $this->getVar('category_title'));
+ if ($isProtected) {
+ $title->setExtra('readonly="readonly"');
+ }
+ $title->setDescription(_AM_SYSTEM_MENUS_TITLECAT_DESC);
+ $form->addElement($title, true);
+ // prefix
+ $editor_configs = array(
+ 'name' => 'category_prefix',
+ 'value' => $this->getVar('category_prefix'),
+ 'rows' => 1,
+ 'cols' => 50,
+ 'width' => '100%',
+ 'height' => '200px',
+ 'editor' => 'Plain Text'
+ );
+ $prefix = new XoopsFormEditor(_AM_SYSTEM_MENUS_PREFIXCAT, 'category_prefix', $editor_configs, false, 'textarea');
+ if ($isProtected) {
+ $prefix->setExtra('readonly="readonly"');
+ if (isset($prefix->editor) && is_object($prefix->editor)) {
+ $prefix->editor->setExtra('readonly="readonly"');
+ }
+ }
+ $prefix->setDescription(_AM_SYSTEM_MENUS_PREFIXCAT_DESC);
+ $form->addElement($prefix, false);
+ // suffix
+ $editor_configs = array(
+ 'name' => 'category_suffix',
+ 'value' => $this->getVar('category_suffix'),
+ 'rows' => 1,
+ 'cols' => 50,
+ 'width' => '100%',
+ 'height' => '200px',
+ 'editor' => 'Plain Text'
+ );
+ $suffix = new XoopsFormEditor(_AM_SYSTEM_MENUS_SUFFIXCAT, 'category_suffix', $editor_configs, false, 'textarea');
+ if ($isProtected) {
+ $suffix->setExtra('readonly="readonly"');
+ if (isset($suffix->editor) && is_object($suffix->editor)) {
+ $suffix->editor->setExtra('readonly="readonly"');
+ }
+ }
+ $suffix->setDescription(_AM_SYSTEM_MENUS_SUFFIXCAT_DESC);
+ $form->addElement($suffix, false);
+ // url
+ $url = new XoopsFormText(_AM_SYSTEM_MENUS_URLCAT, 'category_url', 50, 255, $this->getVar('category_url'));
+ if ($isProtected) {
+ $url->setExtra('readonly="readonly"');
+ }
+ $url->setDescription(_AM_SYSTEM_MENUS_URLCATDESC);
+ $form->addElement($url, false);
+ // target
+ $radio = new XoopsFormRadio(_AM_SYSTEM_MENUS_TARGET, 'category_target', $this->getVar('category_target'));
+ $radio->addOption(0, _AM_SYSTEM_MENUS_TARGET_SELF);
+ $radio->addOption(1, _AM_SYSTEM_MENUS_TARGET_BLANK);
+ $form->addElement($radio, false);
+ // position
+ $form->addElement(new XoopsFormText(_AM_SYSTEM_MENUS_POSITIONCAT, 'category_position', 5, 5, $position));
+
+ // actif
+ $radio = new XoopsFormRadio(_AM_SYSTEM_MENUS_ACTIVE, 'category_active', $active);
+ $radio->addOption(1, _YES);
+ $radio->addOption(0, _NO);
+ $form->addElement($radio);
+
+ // permission
+ $permHelper = new \Xmf\Module\Helper\Permission();
+ $perm = $permHelper->getGroupSelectFormForItem('menus_category_view', $this->getVar('category_id'), _AM_SYSTEM_MENUS_PERMISSION_VIEW_CATEGORY, 'menus_category_view_perms', true);
+ $perm->setDescription(_AM_SYSTEM_MENUS_PERMISSION_VIEW_CATEGORY_DESC);
+ $form->addElement($perm, false);
+
+ $form->addElement(new XoopsFormHidden('op', 'savecat'));
+ // submit
+ $form->addElement(new XoopsFormButton('', 'submit', _SUBMIT, 'submit'));
+
+ return $form;
+ }
+}
+class XoopsMenusCategoryHandler extends XoopsPersistableObjectHandler
+{
+
+ /**
+ * Constructor
+ *
+ * @param XoopsDatabase $db reference to a xoopsDB object
+ */
+ public function __construct($db)
+ {
+ // table short name, class name, key field, identifier field
+ parent::__construct($db, 'menuscategory', 'XoopsMenusCategory', 'category_id', 'category_title');
+ }
+}
\ No newline at end of file
diff --git a/htdocs/kernel/menusitems.php b/htdocs/kernel/menusitems.php
new file mode 100644
index 00000000..f9568add
--- /dev/null
+++ b/htdocs/kernel/menusitems.php
@@ -0,0 +1,306 @@
+initVar('items_id', XOBJ_DTYPE_INT, null, false);
+ $this->initVar('items_pid', XOBJ_DTYPE_INT, 0, false);
+ $this->initVar('items_cid', XOBJ_DTYPE_INT, null, false);
+ $this->initVar('items_title', XOBJ_DTYPE_TXTBOX, null);
+ $this->initVar('items_prefix', XOBJ_DTYPE_TXTAREA);
+ $this->initVar('items_suffix', XOBJ_DTYPE_TXTAREA);
+ $this->initVar('items_url', XOBJ_DTYPE_TXTBOX, null);
+ $this->initVar('items_target', XOBJ_DTYPE_INT, 0);
+ $this->initVar('items_position', XOBJ_DTYPE_INT, null, false);
+ $this->initVar('items_protected', XOBJ_DTYPE_INT, 0);
+ $this->initVar('items_active', XOBJ_DTYPE_INT, 1);
+ // use html
+ $this->initVar('dohtml', XOBJ_DTYPE_INT, 1, false);
+ // Load language file for menu items
+ $language = $GLOBALS['xoopsConfig']['language'] ?? 'english';
+ $fileinc = XOOPS_ROOT_PATH . "/modules/system/language/{$language}/menus/menus.php";
+ if (!isset(self::$languageFilesIncluded[$fileinc])) {
+ if (file_exists($fileinc)) {
+ include_once $fileinc;
+ self::$languageFilesIncluded[$fileinc] = true;
+ }
+ }
+ // Load language file for admin menus
+ $fileinc_admin = XOOPS_ROOT_PATH . "/modules/system/language/{$language}/admin/menus.php";
+ if (!isset(self::$languageFilesIncluded[$fileinc_admin])) {
+ if (file_exists($fileinc_admin)) {
+ include_once $fileinc_admin;
+ self::$languageFilesIncluded[$fileinc_admin] = true;
+ }
+ }
+ }
+
+ /**
+ * Retrieve the resolved title for display.
+ *
+ * If the stored title is a constant name, resolves and returns its value.
+ * Otherwise returns the stored title as-is. Used for front-end rendering.
+ *
+ * Example:
+ * - If items_title = "HOME_LABEL" and HOME_LABEL = "Accueil"
+ * returns "Accueil"
+ * - If items_title = "Custom Text"
+ * returns "Custom Text"
+ *
+ * @return string The resolved title value
+ */
+ public function getResolvedTitle()
+ {
+ $title = $this->getVar('items_title');
+ return defined($title) ? constant($title) : $title;
+ }
+
+ /**
+ * Retrieve the title for administration interface with constant reference.
+ *
+ * If the stored title is a constant name, returns both the resolved value
+ * and the constant name in parentheses. This helps administrators identify
+ * which constant is being used. Otherwise returns the stored title as-is.
+ *
+ * Example:
+ * - If items_title = "HOME_LABEL" and HOME_LABEL = "Accueil"
+ * returns "Accueil (HOME_LABEL)"
+ * - If items_title = "Custom Text"
+ * returns "Custom Text"
+ *
+ * @return string The resolved title with optional constant reference
+ */
+ public function getAdminTitle()
+ {
+ $title = $this->getVar('items_title');
+ if (defined($title)) {
+ return constant($title) . ' (' . $title . ')';
+ } else {
+ return $title;
+ }
+ }
+
+ /**
+ * @return mixed
+ */
+ public function get_new_enreg()
+ {
+ global $xoopsDB;
+ $new_enreg = $xoopsDB->getInsertId();
+
+ return $new_enreg;
+ }
+
+ public function getFormItems($category_id, $action = false)
+ {
+ if ($action === false) {
+ $action = $_SERVER['REQUEST_URI'];
+ }
+ include_once XOOPS_ROOT_PATH . '/class/xoopsformloader.php';
+ //include __DIR__ . '/../include/common.php';
+
+ //form title
+ $title = $this->isNew() ? sprintf(_AM_SYSTEM_MENUS_ADDITEM) : sprintf(_AM_SYSTEM_MENUS_EDITITEM);
+
+ $form = new XoopsThemeForm($title, 'form', $action, 'post', true);
+ $form->setExtra('enctype="multipart/form-data"');
+
+ $isProtected = false;
+ if (!$this->isNew()) {
+ $form->addElement(new XoopsFormHidden('items_id', $this->getVar('items_id')));
+ $position = $this->getVar('items_position');
+ $active = $this->getVar('items_active');
+ $isProtected = (int)$this->getVar('items_protected') === 1;
+ } else {
+ $position = 0;
+ $active = 1;
+ }
+
+ // category
+ $menuscategoryHandler = xoops_getHandler('menuscategory');
+ $category = $menuscategoryHandler->get($category_id);
+ if (!is_object($category)) {
+ $form->addElement(new XoopsFormLabel(_AM_SYSTEM_MENUS_TITLECAT, _AM_SYSTEM_MENUS_ERROR_NOCATEGORY));
+ return $form;
+ }
+ $form->addElement(new XoopsFormLabel(_AM_SYSTEM_MENUS_TITLECAT, $category->getVar('category_title')));
+ $form->addElement(new XoopsFormHidden('items_cid', $category_id));
+
+ // Tree
+ $criteria = new CriteriaCompo();
+ $criteria->add(new Criteria('items_cid', $category_id));
+ $currentParentId = (int)$this->getVar('items_pid');
+ $activeOrCurrentParent = new CriteriaCompo(new Criteria('items_active', 1));
+ if ($currentParentId > 0) {
+ $activeOrCurrentParent->add(new Criteria('items_id', $currentParentId), 'OR');
+ }
+ $criteria->add($activeOrCurrentParent);
+ $criteria->setSort('items_position ASC, items_title');
+ $criteria->setOrder('ASC');
+ $menusitemsHandler = xoops_getHandler('menusitems');
+ $item_arr = $menusitemsHandler->getall($criteria);
+
+ // In edit mode, prevent selecting the current item or one of its descendants as parent.
+ $currentItemId = (int)$this->getVar('items_id');
+ if ($currentItemId > 0 && !empty($item_arr)) {
+ $excludedIds = [$currentItemId => true];
+ $updated = true;
+ while ($updated) {
+ $updated = false;
+ foreach ($item_arr as $obj) {
+ if (!is_object($obj)) {
+ continue;
+ }
+ $itemId = (int)$obj->getVar('items_id');
+ $parentId = (int)$obj->getVar('items_pid');
+ if (!isset($excludedIds[$itemId]) && isset($excludedIds[$parentId])) {
+ $excludedIds[$itemId] = true;
+ $updated = true;
+ }
+ }
+ }
+
+ $item_arr = array_filter($item_arr, static function ($obj) use ($excludedIds) {
+ return is_object($obj) && !isset($excludedIds[(int)$obj->getVar('items_id')]);
+ });
+ }
+
+ // Use admin-friendly title (resolved value + constant name) for select labels
+ foreach ($item_arr as $key => $obj) {
+ if (is_object($obj) && method_exists($obj, 'getAdminTitle')) {
+ $obj->setVar('items_title', $obj->getAdminTitle());
+ $item_arr[$key] = $obj;
+ }
+ }
+ include_once $GLOBALS['xoops']->path('class/tree.php');
+ $myTree = new XoopsObjectTree($item_arr, 'items_id', 'items_pid');
+ $suparticle = $myTree->makeSelectElement('items_pid', 'items_title', '--', $currentParentId, true, 0, '', _AM_SYSTEM_MENUS_PID);
+ if ($isProtected) {
+ $suparticle->setExtra('disabled="disabled"');
+ }
+ $form->addElement($suparticle, false);
+
+ // title
+ $title = new XoopsFormText(_AM_SYSTEM_MENUS_TITLEITEM, 'items_title', 50, 255, $this->getVar('items_title'));
+ if ($isProtected) {
+ $title->setExtra('readonly="readonly"');
+ }
+ $title->setDescription(_AM_SYSTEM_MENUS_TITLEITEM_DESC);
+ $form->addElement($title, true);
+ // prefix
+ $editor_configs = array(
+ 'name' => 'items_prefix',
+ 'value' => $this->getVar('items_prefix'),
+ 'rows' => 1,
+ 'cols' => 50,
+ 'width' => '100%',
+ 'height' => '200px',
+ 'editor' => 'Plain Text'
+ );
+ $prefix = new XoopsFormEditor(_AM_SYSTEM_MENUS_PREFIXITEM, 'items_prefix', $editor_configs, false, 'textarea');
+ if ($isProtected) {
+ $prefix->setExtra('readonly="readonly"');
+ if (isset($prefix->editor) && is_object($prefix->editor)) {
+ $prefix->editor->setExtra('readonly="readonly"');
+ }
+ }
+ $prefix->setDescription(_AM_SYSTEM_MENUS_PREFIXITEM_DESC);
+ $form->addElement($prefix, false);
+ // suffix
+ $editor_configs = array(
+ 'name' => 'items_suffix',
+ 'value' => $this->getVar('items_suffix'),
+ 'rows' => 1,
+ 'cols' => 50,
+ 'width' => '100%',
+ 'height' => '200px',
+ 'editor' => 'Plain Text'
+ );
+ $suffix = new XoopsFormEditor(_AM_SYSTEM_MENUS_SUFFIXITEM, 'items_suffix', $editor_configs, false, 'textarea');
+ if ($isProtected) {
+ $suffix->setExtra('readonly="readonly"');
+ if (isset($suffix->editor) && is_object($suffix->editor)) {
+ $suffix->editor->setExtra('readonly="readonly"');
+ }
+ }
+ $suffix->setDescription(_AM_SYSTEM_MENUS_SUFFIXITEM_DESC);
+ $form->addElement($suffix, false);
+ // url
+ $url = new XoopsFormText(_AM_SYSTEM_MENUS_URLITEM, 'items_url', 50, 255, $this->getVar('items_url'));
+ if ($isProtected) {
+ $url->setExtra('readonly="readonly"');
+ }
+ $form->addElement($url, false);
+ // target
+ $radio = new XoopsFormRadio(_AM_SYSTEM_MENUS_TARGET, 'items_target', $this->getVar('items_target'));
+ $radio->addOption(0, _AM_SYSTEM_MENUS_TARGET_SELF);
+ $radio->addOption(1, _AM_SYSTEM_MENUS_TARGET_BLANK);
+ $form->addElement($radio, false);
+ // position
+ $form->addElement(new XoopsFormText(_AM_SYSTEM_MENUS_POSITIONITEM, 'items_position', 5, 5, $position));
+ // actif
+ $radio = new XoopsFormRadio(_AM_SYSTEM_MENUS_ACTIVE, 'items_active', $active);
+ $radio->addOption(1, _YES);
+ $radio->addOption(0, _NO);
+ $form->addElement($radio);
+
+ // permission
+ $permHelper = new \Xmf\Module\Helper\Permission();
+ $perm = $permHelper->getGroupSelectFormForItem('menus_items_view', $this->getVar('items_id'), _AM_SYSTEM_MENUS_PERMISSION_VIEW_ITEM, 'menus_items_view_perms', true);
+ $perm->setDescription(_AM_SYSTEM_MENUS_PERMISSION_VIEW_ITEM_DESC);
+ $form->addElement($perm, false);
+
+ $form->addElement(new XoopsFormHidden('op', 'saveitem'));
+ // submit
+ $form->addElement(new XoopsFormButton('', 'submit', _SUBMIT, 'submit'));
+
+ return $form;
+ }
+}
+class XoopsMenusItemsHandler extends XoopsPersistableObjectHandler
+{
+
+ /**
+ * Constructor
+ *
+ * @param XoopsDatabase $db reference to a xoopsDB object
+ */
+ public function __construct($db)
+ {
+ // table short name, class name, key field, identifier field
+ parent::__construct($db, 'menusitems', 'XoopsMenusItems', 'items_id', 'items_pid');
+ }
+}
\ No newline at end of file
diff --git a/htdocs/modules/system/admin/menu.php b/htdocs/modules/system/admin/menu.php
index b5c58f24..f439caef 100644
--- a/htdocs/modules/system/admin/menu.php
+++ b/htdocs/modules/system/admin/menu.php
@@ -45,6 +45,7 @@
'icon' => 'fa-solid fa-palette',
'links' => [
['title' => _AM_SYSTEM_TPLSETS, 'url' => 'admin.php?fct=tplsets'],
+ ['title' => _AM_SYSTEM_MENUS, 'url' => 'admin.php?fct=menus'],
['title' => _AM_TAG_FOOTER, 'url' => 'admin.php?fct=preferences&op=show&confcat_id=3'],
],
],
@@ -77,4 +78,3 @@
],
];
-
\ No newline at end of file
diff --git a/htdocs/modules/system/admin/menus/index.php b/htdocs/modules/system/admin/menus/index.php
new file mode 100644
index 00000000..6a505435
--- /dev/null
+++ b/htdocs/modules/system/admin/menus/index.php
@@ -0,0 +1,2 @@
+isAdmin($xoopsModule->mid())) {
+ exit(_NOPERM);
+}
+// Check is active
+$helper = Helper::getHelper('system');
+if (!(int)$helper->getConfig('active_menus', 0)) {
+ redirect_header('admin.php', 2, _AM_SYSTEM_NOTACTIVE);
+}
+
+// Define main template
+$GLOBALS['xoopsOption']['template_main'] = 'system_menus.tpl';
+
+// Get Action type
+$op = Request::getCmd('op', 'list');
+
+// AJAX actions may run without the full theme bootstrap; ensure static menu helpers are available.
+if (!class_exists('xos_opal_Theme', false)) {
+ include_once XOOPS_ROOT_PATH . '/class/theme.php';
+}
+
+$sendJsonResponse = static function (array $payload, $statusCode = 200) {
+ if (!headers_sent()) {
+ http_response_code((int)$statusCode);
+ header('Content-Type: application/json; charset=utf-8');
+ header('X-Content-Type-Options: nosniff');
+ }
+ echo json_encode($payload);
+ exit;
+};
+
+// Call Header
+if ($op !== 'saveorder' && $op !== 'toggleactivecat' && $op !== 'toggleactiveitem') {
+ xoops_cp_header();
+ $xoopsTpl->assign('op', $op);
+ $xoopsTpl->assign('xoops_token', $GLOBALS['xoopsSecurity']->getTokenHTML());
+
+ // Define Stylesheet
+ $xoTheme->addStylesheet(XOOPS_URL . '/modules/system/css/admin.css');
+ $xoTheme->addStylesheet(XOOPS_URL . '/modules/system/css/menus.css');
+ // Define scripts
+ $xoTheme->addScript('modules/system/js/admin.js');
+ $xoTheme->addScript('modules/system/js/menus.js');
+ // Define Breadcrumb and tips
+}
+
+switch ($op) {
+ case 'list':
+ default:
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->addTips(sprintf(_AM_SYSTEM_MENUS_NAV_TIPS, $GLOBALS['xoopsConfig']['language']));
+ $xoBreadCrumb->render();
+ /** @var \XoopsPersistableObjectHandler $menuscategoryHandler */
+ $menuscategoryHandler = xoops_getHandler('menuscategory');
+ $criteria = new CriteriaCompo();
+ $criteria->setSort('category_position');
+ $criteria->setOrder('ASC');
+ $category_arr = $menuscategoryHandler->getall($criteria);
+ $category_count = $menuscategoryHandler->getCount($criteria);
+ $xoopsTpl->assign('category_count', $category_count);
+ if ($category_count > 0) {
+ foreach (array_keys($category_arr) as $i) {
+ $category = array();
+ $category['id'] = $category_arr[$i]->getVar('category_id');
+ $category['title'] = $category_arr[$i]->getAdminTitle();
+ $category['prefix'] = $category_arr[$i]->getVar('category_prefix');
+ $category['suffix'] = $category_arr[$i]->getVar('category_suffix');
+ $category['url'] = xos_opal_Theme::normalizeMenuUrl($category_arr[$i]->getVar('category_url'));
+ $category['target'] = ($category_arr[$i]->getVar('category_target') == 1) ? '_blank' : '_self';
+ $category['position'] = $category_arr[$i]->getVar('category_position');
+ $category['active'] = $category_arr[$i]->getVar('category_active');
+ $category['protected'] = $category_arr[$i]->getVar('category_protected');
+ $xoopsTpl->append('category', $category);
+ unset($category);
+ }
+ } else {
+ $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
+ }
+ break;
+
+ case 'addcat':
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->render();
+ // Form
+ $menuscategoryHandler = xoops_getHandler('menuscategory');
+ /** @var \XoopsMenusCategory $obj */
+ $obj = $menuscategoryHandler->create();
+ $form = $obj->getFormCat();
+ $xoopsTpl->assign('form', $form->render());
+ break;
+
+ case 'editcat':
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->render();
+ // Form
+ $category_id = Request::getInt('category_id', 0);
+ if ($category_id == 0) {
+ $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
+ } else {
+ $menuscategoryHandler = xoops_getHandler('menuscategory');
+ /** @var \XoopsMenusCategory $obj */
+ $obj = $menuscategoryHandler->get($category_id);
+ if (!is_object($obj)) {
+ $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
+ } else {
+ $form = $obj->getFormCat();
+ $xoopsTpl->assign('form', $form->render());
+ }
+ }
+ break;
+
+ case 'savecat':
+ if (!$GLOBALS['xoopsSecurity']->check()) {
+ redirect_header('admin.php?fct=menus', 3, implode('
', $GLOBALS['xoopsSecurity']->getErrors()));
+ }
+ $menuscategoryHandler = xoops_getHandler('menuscategory');
+ $id = Request::getInt('category_id', 0);
+ $isProtected = false;
+ if ($id > 0) {
+ $obj = $menuscategoryHandler->get($id);
+ if (!is_object($obj)) {
+ redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
+ }
+ $isProtected = (int)$obj->getVar('category_protected') === 1;
+ } else {
+ $obj = $menuscategoryHandler->create();
+ }
+ // Server-side lock: protected categories keep immutable label and rendering fields.
+ if (!$isProtected) {
+ $obj->setVar('category_title', Request::getString('category_title', ''));
+ $obj->setVar('category_prefix', Request::getText('category_prefix', ''));
+ $obj->setVar('category_suffix', Request::getText('category_suffix', ''));
+ $obj->setVar('category_url', Request::getString('category_url', ''));
+ }
+ $obj->setVar('category_target', Request::getInt('category_target', 0));
+ $obj->setVar('category_position', Request::getInt('category_position', 0));
+ $obj->setVar('category_active', Request::getInt('category_active', 1));
+ if ($menuscategoryHandler->insert($obj)) {
+ // permissions
+ if ($obj->get_new_enreg() == 0) {
+ $perm_id = $obj->getVar('category_id');
+ } else {
+ $perm_id = $obj->get_new_enreg();
+ }
+ $permHelper = new \Xmf\Module\Helper\Permission();
+ // permission view
+ $groups_view = Request::getArray('menus_category_view_perms', [], 'POST');
+ $permHelper->savePermissionForItem('menus_category_view', $perm_id, $groups_view);
+ xos_opal_Theme::invalidateMenusCache();
+ redirect_header('admin.php?fct=menus', 2, _AM_SYSTEM_DBUPDATED);
+ } else {
+ $form = $obj->getFormCat();
+ $xoopsTpl->assign('form', $form->render());
+ $xoopsTpl->assign('error_message', $obj->getHtmlErrors());
+ }
+ break;
+
+ case 'delcat':
+ $category_id = Request::getInt('category_id', 0);
+ if ($category_id == 0) {
+ redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
+ } else {
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->render();
+ $surdel = Request::getBool('surdel', false);
+ $menuscategoryHandler = xoops_getHandler('menuscategory');
+ $menusitemsHandler = xoops_getHandler('menusitems');
+ /** @var \XoopsMenusCategory $obj */
+ $obj = $menuscategoryHandler->get($category_id);
+ if (!is_object($obj)) {
+ redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
+ }
+ if (is_object($obj) && (int)$obj->getVar('category_protected') === 1) {
+ redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_CATPROTECTED);
+ }
+ if ($surdel === true) {
+ if (!$GLOBALS['xoopsSecurity']->check()) {
+ redirect_header('admin.php?fct=menus', 3, implode('
', $GLOBALS['xoopsSecurity']->getErrors()));
+ }
+ if ($menuscategoryHandler->delete($obj)) {
+ // Del permissions category
+ $permHelper = new \Xmf\Module\Helper\Permission();
+ $permHelper->deletePermissionForItem('menus_category_view', $category_id);
+ // delete items in this category
+ $criteria = new CriteriaCompo();
+ $criteria->add(new Criteria('items_cid', $category_id));
+ $items_arr = $menusitemsHandler->getall($criteria);
+ foreach (array_keys($items_arr) as $i) {
+ // Del permissions item
+ $permHelper = new \Xmf\Module\Helper\Permission();
+ $permHelper->deletePermissionForItem('menus_items_view', $items_arr[$i]->getVar('items_id'));
+ $menusitemsHandler->delete($items_arr[$i]);
+ }
+ xos_opal_Theme::invalidateMenusCache();
+ redirect_header('admin.php?fct=menus', 2, _AM_SYSTEM_DBUPDATED);
+ } else {
+ $xoopsTpl->assign('error_message', $obj->getHtmlErrors());
+ }
+ } else {
+ $criteria = new CriteriaCompo();
+ $criteria->add(new Criteria('items_cid', $category_id));
+ $items_arr = $menusitemsHandler->getall($criteria);
+ $items = '
';
+ foreach (array_keys($items_arr) as $i) {
+ $items .= '#' . $items_arr[$i]->getVar('items_id') . ': ' . $items_arr[$i]->getVar('items_title') . '
';
+ }
+ xoops_confirm([
+ 'surdel' => true,
+ 'category_id' => $category_id,
+ 'op' => 'delcat'
+ ], $_SERVER['REQUEST_URI'], sprintf(_AM_SYSTEM_MENUS_SUREDELCAT, $obj->getVar('category_title')) . $items);
+ }
+ }
+ break;
+
+ case 'delitem':
+ $item_id = Request::getInt('item_id', 0);
+ if ($item_id == 0) {
+ redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_NOITEM);
+ } else {
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->render();
+ include_once $GLOBALS['xoops']->path('class/tree.php');
+ $surdel = Request::getBool('surdel', false);
+ $menusitemsHandler = xoops_getHandler('menusitems');
+ /** @var \XoopsMenusItems $obj */
+ $obj = $menusitemsHandler->get($item_id);
+ if (!is_object($obj)) {
+ redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_NOITEM);
+ }
+ $category_id = (int)$obj->getVar('items_cid');
+ if (is_object($obj) && (int)$obj->getVar('items_protected') === 1) {
+ redirect_header('admin.php?fct=menus&op=viewcat&category_id=' . $category_id, 5, _AM_SYSTEM_MENUS_ERROR_ITEMPROTECTED);
+ }
+ if ($obj->getVar('items_active') == 0){
+ redirect_header('admin.php?fct=menus&op=viewcat&category_id=' . $category_id, 5, _AM_SYSTEM_MENUS_ERROR_ITEMDISABLE);
+ }
+ if ($surdel === true) {
+ if (!$GLOBALS['xoopsSecurity']->check()) {
+ redirect_header('admin.php?fct=menus', 3, implode('
', $GLOBALS['xoopsSecurity']->getErrors()));
+ }
+ if ($menusitemsHandler->delete($obj)) {
+ // Del permissions item
+ $permHelper = new \Xmf\Module\Helper\Permission();
+ $permHelper->deletePermissionForItem('menus_items_view', $item_id);
+ // delete subitems of this item
+ $criteria = new CriteriaCompo();
+ $criteria->add(new Criteria('items_cid', $category_id));
+ $items_arr = $menusitemsHandler->getall($criteria);
+ $myTree = new XoopsObjectTree($items_arr, 'items_id', 'items_pid');
+ $items_arr = $myTree->getAllChild($item_id);
+ foreach (array_keys($items_arr) as $i) {
+ // Del permissions subitem
+ $permHelper = new \Xmf\Module\Helper\Permission();
+ $permHelper->deletePermissionForItem('menus_items_view', $items_arr[$i]->getVar('items_id'));
+ $menusitemsHandler->delete($items_arr[$i]);
+ }
+ xos_opal_Theme::invalidateMenusCache();
+ redirect_header('admin.php?fct=menus&op=viewcat&category_id=' . $category_id, 2, _AM_SYSTEM_DBUPDATED);
+ } else {
+ $xoopsTpl->assign('error_message', $obj->getHtmlErrors());
+ }
+ } else {
+ $criteria = new CriteriaCompo();
+ $criteria->add(new Criteria('items_cid', $category_id));
+ $items_arr = $menusitemsHandler->getall($criteria);
+ $myTree = new XoopsObjectTree($items_arr, 'items_id', 'items_pid');
+ $items_arr = $myTree->getAllChild($item_id);
+ $items = '
';
+ foreach (array_keys($items_arr) as $i) {
+ $items .= '#' . $items_arr[$i]->getVar('items_id') . ': ' . $items_arr[$i]->getVar('items_title') . '
';
+ }
+ xoops_confirm([
+ 'surdel' => true,
+ 'item_id' => $item_id,
+ 'category_id' => $category_id,
+ 'op' => 'delitem'
+ ], $_SERVER['REQUEST_URI'], sprintf(_AM_SYSTEM_MENUS_SUREDELITEM, $obj->getVar('items_title')) . $items);
+ }
+ }
+ break;
+
+ case 'saveorder':
+ // Pour les réponses AJAX : désactiver le logger et vider les buffers
+ if (isset($GLOBALS['xoopsLogger']) && is_object($GLOBALS['xoopsLogger'])) {
+ $GLOBALS['xoopsLogger']->activated = false;
+ }
+ while (ob_get_level()) {
+ @ob_end_clean();
+ }
+ try {
+ if (!$GLOBALS['xoopsSecurity']->check()) {
+ $errors = $GLOBALS['xoopsSecurity']->getErrors();
+ $sendJsonResponse([
+ 'success' => false,
+ 'message' => implode(' ', $errors),
+ 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()
+ ], 400);
+ }
+
+ $order = Request::getArray('order', []);
+ if (!is_array($order) || count($order) === 0) {
+ $sendJsonResponse([
+ 'success' => false,
+ 'message' => 'No order provided',
+ 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()
+ ], 400);
+ }
+
+ $menuscategoryHandler = xoops_getHandler('menuscategory');
+ if (!is_object($menuscategoryHandler) && class_exists('XoopsMenusCategoryHandler')) {
+ $menuscategoryHandler = new XoopsMenusCategoryHandler($GLOBALS['xoopsDB']);
+ }
+ if (!is_object($menuscategoryHandler)) {
+ throw new RuntimeException('Unable to initialize menus category handler.');
+ }
+
+ $pos = 1;
+ $errors = [];
+ foreach ($order as $id) {
+ $id = (int)$id;
+ if ($id <= 0) {
+ continue;
+ }
+ $obj = $menuscategoryHandler->get($id);
+ if (is_object($obj)) {
+ $obj->setVar('category_position', $pos);
+ if (!$menuscategoryHandler->insert($obj, true)) {
+ $errors[] = "Failed to update id {$id}";
+ }
+ } else {
+ $errors[] = "Not found id {$id}";
+ }
+ $pos++;
+ }
+
+ if (empty($errors)) {
+ xos_opal_Theme::invalidateMenusCache();
+ $sendJsonResponse([
+ 'success' => true,
+ 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()
+ ]);
+ }
+
+ $sendJsonResponse([
+ 'success' => false,
+ 'message' => implode('; ', $errors),
+ 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()
+ ], 500);
+ } catch (Throwable $e) {
+ error_log(sprintf(
+ '[menus.saveorder] %s in %s:%d',
+ $e->getMessage(),
+ $e->getFile(),
+ $e->getLine()
+ ));
+ $sendJsonResponse([
+ 'success' => false,
+ 'message' => 'Internal server error',
+ 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()
+ ], 500);
+ }
+ break;
+
+ case 'viewcat':
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_CATEGORY);
+ $xoBreadCrumb->render();
+ $category_id = Request::getInt('category_id', 0);
+ $xoopsTpl->assign('category_id', $category_id);
+ if ($category_id == 0) {
+ $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
+ } else {
+ $menuscategoryHandler = xoops_getHandler('menuscategory');
+ $category = $menuscategoryHandler->get($category_id);
+ if (!is_object($category)) {
+ $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
+ break;
+ }
+ $xoopsTpl->assign('category_id', $category->getVar('category_id'));
+ $xoopsTpl->assign('cat_title', $category->getAdminTitle());
+
+ $menusitemsHandler = xoops_getHandler('menusitems');
+ $criteria = new CriteriaCompo();
+ $criteria->add(new Criteria('items_cid', $category_id));
+ $criteria->setSort('items_position, items_title');
+ $criteria->setOrder('ASC');
+ $items_arr = $menusitemsHandler->getall($criteria);
+ $items_count = $menusitemsHandler->getCount($criteria);
+ $xoopsTpl->assign('items_count', $items_count);
+ include_once $GLOBALS['xoops']->path('class/tree.php');
+ $myTree = new XoopsObjectTree($items_arr, 'items_id', 'items_pid');
+ $tree = $myTree->getTree();
+ $tree_arr = [];
+ $prefix = '--';
+ $buildTree = static function ($key, $prefix_curr, $level) use (&$buildTree, &$tree, &$tree_arr, $prefix) {
+ if ($key > 0 && isset($tree[$key]['obj'])) {
+ $tree_arr[] = [
+ 'name' => $prefix_curr . ' ' . $tree[$key]['obj']->getVar('items_title'),
+ 'id' => $tree[$key]['obj']->getVar('items_id'),
+ 'level' => $level,
+ 'obj' => $tree[$key]['obj'],
+ ];
+ $prefix_curr .= $prefix;
+ $level++;
+ }
+ if (isset($tree[$key]['child']) && !empty($tree[$key]['child'])) {
+ foreach ($tree[$key]['child'] as $childKey) {
+ $buildTree($childKey, $prefix_curr, $level);
+ }
+ }
+ };
+ $buildTree(0, '', 1);
+ if ($items_count > 0) {
+ foreach (array_keys($tree_arr) as $i) {
+ $items = array();
+ $items['id'] = $tree_arr[$i]['obj']->getVar('items_id');
+ $items['title'] = $tree_arr[$i]['obj']->getAdminTitle();
+ $items['prefix'] = $tree_arr[$i]['obj']->getVar('items_prefix');
+ $items['suffix'] = $tree_arr[$i]['obj']->getVar('items_suffix');
+ $items['url'] = xos_opal_Theme::normalizeMenuUrl($tree_arr[$i]['obj']->getVar('items_url'));
+ $items['target'] = ($tree_arr[$i]['obj']->getVar('items_target') == 1) ? '_blank' : '_self';
+ $items['active'] = $tree_arr[$i]['obj']->getVar('items_active');
+ $items['protected'] = $tree_arr[$i]['obj']->getVar('items_protected');
+ $items['pid'] = $tree_arr[$i]['obj']->getVar('items_pid');
+ $items['cid'] = $tree_arr[$i]['obj']->getVar('items_cid');
+ $items['level'] = ($tree_arr[$i]['level'] - 1);
+ $xoopsTpl->append('items', $items);
+ unset($items);
+ }
+ }
+ }
+ break;
+
+ case 'additem':
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_CATEGORY);
+ $xoBreadCrumb->render();
+ $category_id = Request::getInt('category_id', 0);
+ $xoopsTpl->assign('category_id', $category_id);
+ // Form
+ $menusitemsHandler = xoops_getHandler('menusitems');
+ /** @var \XoopsMenusItems $obj */
+ $obj = $menusitemsHandler->create();
+ $form = $obj->getFormItems($category_id);
+ $xoopsTpl->assign('form', $form->render());
+ break;
+
+ case 'saveitem':
+ if (!$GLOBALS['xoopsSecurity']->check()) {
+ redirect_header('admin.php?fct=menus', 3, implode('
', $GLOBALS['xoopsSecurity']->getErrors()));
+ }
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_CATEGORY);
+ $xoBreadCrumb->render();
+
+ $menusitemsHandler = xoops_getHandler('menusitems');
+ $id = Request::getInt('items_id', 0);
+ $isProtected = false;
+ /** @var \XoopsMenusItems $obj */
+ if ($id > 0) {
+ $obj = $menusitemsHandler->get($id);
+ if (!is_object($obj)) {
+ $itemsCid = Request::getInt('items_cid', 0);
+ redirect_header('admin.php?fct=menus&op=viewcat&category_id=' . $itemsCid, 3, _AM_SYSTEM_MENUS_ERROR_NOITEM);
+ }
+ $isProtected = (int)$obj->getVar('items_protected') === 1;
+ } else {
+ $obj = $menusitemsHandler->create();
+ }
+ $oldCid = ($id > 0) ? (int)$obj->getVar('items_cid') : 0;
+ // On edit, category is immutable: always trust persisted value.
+ $items_cid = ($id > 0) ? $oldCid : Request::getInt('items_cid', 0);
+ if ($id === 0 && !$isProtected) {
+ $obj->setVar('items_cid', $items_cid);
+ }
+ $error_message = '';
+ if (!$isProtected) {
+ // Validate that the target category exists
+ $menuscategoryHandler = xoops_getHandler('menuscategory');
+ if ($items_cid === 0 || !is_object($menuscategoryHandler->get($items_cid))) {
+ $error_message .= _AM_SYSTEM_MENUS_ERROR_INVALIDCAT;
+ }
+ }
+ if (!$isProtected) {
+ $itempid = Request::getInt('items_pid', 0);
+ if ($itempid != 0 && $itempid === $id) {
+ // self-parent
+ $error_message .= _AM_SYSTEM_MENUS_ERROR_ITEMPARENT;
+ } elseif ($itempid != 0) {
+ // parent must exist in the same category
+ $parentObj = $menusitemsHandler->get($itempid);
+ if (!is_object($parentObj) || (int)$parentObj->getVar('items_cid') !== $items_cid) {
+ $error_message .= _AM_SYSTEM_MENUS_ERROR_ITEMPARENT;
+ } elseif ($id > 0) {
+ // parent must not be a descendant of the current item
+ $allInCat = $menusitemsHandler->getall(new Criteria('items_cid', $items_cid));
+ $descendants = [];
+ $queue = [$id];
+ while (!empty($queue)) {
+ $cur = array_shift($queue);
+ foreach ($allInCat as $candidate) {
+ if ((int)$candidate->getVar('items_pid') === $cur) {
+ $did = (int)$candidate->getVar('items_id');
+ if (!in_array($did, $descendants, true)) {
+ $descendants[] = $did;
+ $queue[] = $did;
+ }
+ }
+ }
+ }
+ if (in_array($itempid, $descendants, true)) {
+ $error_message .= _AM_SYSTEM_MENUS_ERROR_ITEMPARENT;
+ } else {
+ $obj->setVar('items_pid', $itempid);
+ }
+ } else {
+ $obj->setVar('items_pid', $itempid);
+ }
+ } else {
+ $obj->setVar('items_pid', null);
+ }
+ }
+ if (!$isProtected) {
+ $obj->setVar('items_title', Request::getString('items_title', ''));
+ $obj->setVar('items_prefix', Request::getText('items_prefix', ''));
+ $obj->setVar('items_suffix', Request::getText('items_suffix', ''));
+ $obj->setVar('items_url', Request::getString('items_url', ''));
+ }
+ $obj->setVar('items_position', Request::getInt('items_position', 0));
+ $obj->setVar('items_target', Request::getInt('items_target', 0));
+ $obj->setVar('items_active', Request::getInt('items_active', 1));
+ if ($error_message == '') {
+ if ($menusitemsHandler->insert($obj)) {
+ // permissions
+ if ($obj->get_new_enreg() == 0) {
+ $perm_id = $obj->getVar('items_id');
+ } else {
+ $perm_id = $obj->get_new_enreg();
+ }
+ $permHelper = new \Xmf\Module\Helper\Permission();
+ // permission view
+ $groups_view = Request::getArray('menus_items_view_perms', [], 'POST');
+ $permHelper->savePermissionForItem('menus_items_view', $perm_id, $groups_view);
+ xos_opal_Theme::invalidateMenusCache();
+ redirect_header('admin.php?fct=menus&op=viewcat&category_id=' . $items_cid, 2, _AM_SYSTEM_DBUPDATED);
+ } else {
+ /** @var \XoopsMenusItems $obj */
+ $form = $obj->getFormItems($items_cid);
+ $xoopsTpl->assign('form', $form->render());
+ $xoopsTpl->assign('error_message', $obj->getHtmlErrors());
+ }
+ } else {
+ /** @var \XoopsMenusItems $obj */
+ $form = $obj->getFormItems($items_cid);
+ $xoopsTpl->assign('form', $form->render());
+ $xoopsTpl->assign('error_message', $error_message);
+ }
+ break;
+
+ case 'edititem':
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_CATEGORY);
+ $xoBreadCrumb->render();
+ // Form
+ $item_id = Request::getInt('item_id', 0);
+ $category_id = Request::getInt('category_id', 0);
+ $xoopsTpl->assign('category_id', $category_id);
+ if ($item_id == 0 || $category_id == 0) {
+ if ($item_id == 0) {
+ $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOITEM);
+ }
+ if ($category_id == 0) {
+ $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
+ }
+ } else {
+ $menusitemsHandler = xoops_getHandler('menusitems');
+ /** @var \XoopsMenusItems $obj */
+ $obj = $menusitemsHandler->get($item_id);
+ if (!is_object($obj)) {
+ $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOITEM);
+ break;
+ }
+ if ($obj->getVar('items_active') == 0){
+ redirect_header('admin.php?fct=menus&op=viewcat&category_id=' . $category_id, 5, _AM_SYSTEM_MENUS_ERROR_ITEMEDIT);
+ }
+ $form = $obj->getFormItems($category_id);
+ $xoopsTpl->assign('form', $form->render());
+ }
+ break;
+
+ case 'toggleactivecat':
+ // Pour les réponses AJAX : désactiver le logger et vider les buffers
+ if (isset($GLOBALS['xoopsLogger']) && is_object($GLOBALS['xoopsLogger'])) {
+ $GLOBALS['xoopsLogger']->activated = false;
+ }
+ while (ob_get_level()) {
+ @ob_end_clean();
+ }
+ // Vérifier token
+ if (!$GLOBALS['xoopsSecurity']->check()) {
+ header('Content-Type: application/json');
+ echo json_encode([
+ 'success' => false,
+ 'message' => implode(' ', $GLOBALS['xoopsSecurity']->getErrors()),
+ 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()
+ ]);
+ exit;
+ }
+
+ $category_id = Request::getInt('category_id', 0);
+ if ($category_id <= 0) {
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'message' => 'Invalid id', 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()]);
+ exit;
+ }
+
+ $menuscategoryHandler = xoops_getHandler('menuscategory');
+ if (!is_object($menuscategoryHandler) && class_exists('XoopsMenusCategoryHandler')) {
+ $menuscategoryHandler = new XoopsMenusCategoryHandler($GLOBALS['xoopsDB']);
+ }
+
+ $obj = $menuscategoryHandler->get($category_id);
+ if (!is_object($obj)) {
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'message' => 'Not found', 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()]);
+ exit;
+ }
+ $new = $obj->getVar('category_active') ? 0 : 1;
+ $obj->setVar('category_active', $new);
+ $res = $menuscategoryHandler->insert($obj, true);
+
+ // cascade to all items of this category (and their children)
+ $updatedItems = [];
+ if ($res) {
+ $menusitemsHandler = xoops_getHandler('menusitems');
+ if (!is_object($menusitemsHandler) && class_exists('XoopsMenusItemsHandler')) {
+ $menusitemsHandler = new XoopsMenusItemsHandler($GLOBALS['xoopsDB']);
+ }
+ $critCat = new Criteria('items_cid', $category_id);
+ $allItems = $menusitemsHandler->getAll($critCat);
+ $childrenByParent = [];
+ foreach ($allItems as $itm) {
+ $childrenByParent[(int)$itm->getVar('items_pid')][] = $itm;
+ }
+ $visited = [];
+ /**
+ * Recursively update items under a parent using the in-memory tree.
+ */
+ $recursiveUpdate = function ($handler, array $itemsByParent, $parentId, $state, array &$updated, array &$seen) use (&$recursiveUpdate) {
+ foreach ($itemsByParent[(int)$parentId] ?? [] as $child) {
+ $cid = (int)$child->getVar('items_id');
+ if (isset($seen[$cid])) {
+ continue;
+ }
+ $seen[$cid] = true;
+ if ((int)$child->getVar('items_active') !== $state) {
+ $child->setVar('items_active', $state);
+ if ($handler->insert($child, true)) {
+ $updated[] = $cid;
+ }
+ }
+ $recursiveUpdate($handler, $itemsByParent, $cid, $state, $updated, $seen);
+ }
+ };
+ $recursiveUpdate($menusitemsHandler, $childrenByParent, 0, $new, $updatedItems, $visited);
+ foreach ($allItems as $itm) {
+ $idtmp = (int)$itm->getVar('items_id');
+ if (!isset($visited[$idtmp])) {
+ $recursiveUpdate($menusitemsHandler, $childrenByParent, (int)$itm->getVar('items_pid'), $new, $updatedItems, $visited);
+ }
+ }
+ }
+
+ header('Content-Type: application/json');
+ if ($res) {
+ xos_opal_Theme::invalidateMenusCache();
+ $response = ['success' => true, 'active' => (int)$new, 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()];
+ if (!empty($updatedItems)) {
+ $response['updated'] = array_values($updatedItems);
+ }
+ echo json_encode($response);
+ } else {
+ echo json_encode(['success' => false, 'message' => 'Save failed', 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()]);
+ }
+ exit;
+ break;
+
+ case 'toggleactiveitem':
+ // Disable logger & clear buffers for clean JSON response
+ if (isset($GLOBALS['xoopsLogger']) && is_object($GLOBALS['xoopsLogger'])) {
+ $GLOBALS['xoopsLogger']->activated = false;
+ }
+ while (ob_get_level()) {
+ @ob_end_clean();
+ }
+
+ // token check
+ if (!$GLOBALS['xoopsSecurity']->check()) {
+ header('Content-Type: application/json');
+ echo json_encode([
+ 'success' => false,
+ 'message' => implode(' ', $GLOBALS['xoopsSecurity']->getErrors()),
+ 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()
+ ]);
+ exit;
+ }
+
+ $item_id = Request::getInt('item_id', 0);
+ if ($item_id <= 0) {
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'message' => 'Invalid id', 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()]);
+ exit;
+ }
+
+ $menusitemsHandler = xoops_getHandler('menusitems');
+ if (!is_object($menusitemsHandler) && class_exists('XoopsMenusItemsHandler')) {
+ $menusitemsHandler = new XoopsMenusItemsHandler($GLOBALS['xoopsDB']);
+ }
+
+ $obj = $menusitemsHandler->get($item_id);
+ if (!is_object($obj)) {
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'message' => 'Not found', 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()]);
+ exit;
+ }
+
+ // Adapt field name si nécessaire (ici 'items_active')
+ $current = (int)$obj->getVar('items_active');
+ $new = $current ? 0 : 1;
+ // if we try to activate, ensure the parent/ancestors are active
+ if ($new) {
+ $categoryId = (int)$obj->getVar('items_cid');
+ $menuscategoryHandler = xoops_getHandler('menuscategory');
+ if (!is_object($menuscategoryHandler) && class_exists('XoopsMenusCategoryHandler')) {
+ $menuscategoryHandler = new XoopsMenusCategoryHandler($GLOBALS['xoopsDB']);
+ }
+ $categoryObj = $menuscategoryHandler->get($categoryId);
+ if (!is_object($categoryObj) || (int)$categoryObj->getVar('category_active') === 0) {
+ header('Content-Type: application/json');
+ echo json_encode([
+ 'success' => false,
+ 'message' => _AM_SYSTEM_MENUS_ERROR_PARENTINACTIVE,
+ 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()
+ ]);
+ exit;
+ }
+ $parentId = (int)$obj->getVar('items_pid');
+ while ($parentId > 0) {
+ $parentObj = $menusitemsHandler->get($parentId);
+ if (!is_object($parentObj)) {
+ break;
+ }
+ if ((int)$parentObj->getVar('items_active') === 0) {
+ header('Content-Type: application/json');
+ echo json_encode([
+ 'success' => false,
+ 'message' => _AM_SYSTEM_MENUS_ERROR_PARENTINACTIVE,
+ 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()
+ ]);
+ exit;
+ }
+ $parentId = (int)$parentObj->getVar('items_pid');
+ }
+ }
+ $obj->setVar('items_active', $new);
+ $res = $menusitemsHandler->insert($obj, true);
+
+ // propagate new state to all children recursively
+ $updatedChildren = [];
+ if ($res) {
+ /**
+ * Recursively update child items to match parent state
+ *
+ * @param \XoopsPersistableObjectHandler $handler
+ * @param int $parentId
+ * @param int $state
+ * @param array $updated (passed by reference)
+ */
+ function propagateActiveState($handler, $parentId, $state, array &$updated)
+ {
+ $criteria = new Criteria('items_pid', (int)$parentId);
+ /** @var \XoopsObject[] $children */
+ $children = $handler->getAll($criteria);
+ foreach ($children as $child) {
+ $childId = $child->getVar('items_id');
+ if ((int)$child->getVar('items_active') !== $state) {
+ $child->setVar('items_active', $state);
+ if ($handler->insert($child, true)) {
+ $updated[] = $childId;
+ }
+ }
+ propagateActiveState($handler, $childId, $state, $updated);
+ }
+ }
+
+ propagateActiveState($menusitemsHandler, $item_id, $new, $updatedChildren);
+ }
+
+ header('Content-Type: application/json');
+ if ($res) {
+ xos_opal_Theme::invalidateMenusCache();
+ $response = ['success' => true, 'active' => (int)$new, 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()];
+ if (!empty($updatedChildren)) {
+ $response['updated'] = array_values($updatedChildren);
+ }
+ echo json_encode($response);
+ } else {
+ echo json_encode(['success' => false, 'message' => 'Save failed', 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()]);
+ }
+ exit;
+ break;
+
+}
+if ($op !== 'saveorder' && $op !== 'toggleactivecat' && $op !== 'toggleactiveitem') {
+ // Call Footer
+ xoops_cp_footer();
+}
\ No newline at end of file
diff --git a/htdocs/modules/system/admin/menus/xoops_version.php b/htdocs/modules/system/admin/menus/xoops_version.php
new file mode 100644
index 00000000..1ed942bc
--- /dev/null
+++ b/htdocs/modules/system/admin/menus/xoops_version.php
@@ -0,0 +1,35 @@
+ _AM_SYSTEM_MENUS,
+ 'version' => '1.0',
+ 'description' => _AM_SYSTEM_MENUS_DESC,
+ 'author' => '',
+ 'credits' => 'XOOPS Development Team, Grégory Mage (AKA GregMage)',
+ 'help' => 'page=menus',
+ 'license' => 'GPL see LICENSE',
+ 'official' => 1,
+ 'image' => 'db.png',
+ 'icon' => 'fa fa-bars',
+ 'hasAdmin' => 1,
+ 'adminpath' => 'admin.php?fct=menus',
+ 'category' => XOOPS_SYSTEM_MENUS,
+ ];
\ No newline at end of file
diff --git a/htdocs/modules/system/admin/modulesadmin/modulesadmin.php b/htdocs/modules/system/admin/modulesadmin/modulesadmin.php
index a1c89229..60458b4a 100644
--- a/htdocs/modules/system/admin/modulesadmin/modulesadmin.php
+++ b/htdocs/modules/system/admin/modulesadmin/modulesadmin.php
@@ -68,6 +68,8 @@ function xoops_module_install($dirname)
'banner',
'bannerclient',
'bannerfinish',
+ 'menuscategory',
+ 'menusitems',
];
/** @var XoopsModuleHandler $module_handler */
$module_handler = xoops_getHandler('module');
@@ -614,6 +616,8 @@ function xoops_module_uninstall($dirname)
'tplset',
'tplsource',
'xoopsnotifications',
+ 'menuscategory',
+ 'menusitems',
'banner',
'bannerclient',
'bannerfinish',
diff --git a/htdocs/modules/system/constants.php b/htdocs/modules/system/constants.php
index 8410fc91..406beaf8 100644
--- a/htdocs/modules/system/constants.php
+++ b/htdocs/modules/system/constants.php
@@ -34,6 +34,7 @@
define('XOOPS_SYSTEM_FILEMANAGER', 16);
define('XOOPS_SYSTEM_MAINTENANCE', 17);
define("XOOPS_SYSTEM_THEME1", 18);
+define("XOOPS_SYSTEM_MENUS", 19);
// Configuration Category
define('SYSTEM_CAT_MAIN', 0);
define('SYSTEM_CAT_USER', 1);
diff --git a/htdocs/modules/system/css/menus.css b/htdocs/modules/system/css/menus.css
new file mode 100644
index 00000000..67956a08
--- /dev/null
+++ b/htdocs/modules/system/css/menus.css
@@ -0,0 +1,25 @@
+#menus-row [data-id] { cursor: move; }
+.card-placeholder { border: 2px dashed #ccc; height: 80px; margin-bottom: .75rem; }
+.submenu-indicator { width:1.2rem; display:inline-flex; justify-content:center; align-items:center; color:#6c757d; margin-right:.5rem; }
+
+/* visually dim inactivated items/cards */
+.list-group-item.inactive,
+.card.inactive {
+ opacity: 0.5;
+}
+
+/* badge in disabled state because parent is off */
+.item-active-toggle.disabled,
+.category-active-toggle.disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ cursor: not-allowed;
+}
+
+/* action links/buttons disabled when item itself is off */
+.btn-group .btn.disabled,
+.btn-group .btn[aria-disabled="true"] {
+ opacity: 0.65;
+ pointer-events: none;
+}
+
diff --git a/htdocs/modules/system/css/multilevelmenu.css b/htdocs/modules/system/css/multilevelmenu.css
new file mode 100644
index 00000000..1482415a
--- /dev/null
+++ b/htdocs/modules/system/css/multilevelmenu.css
@@ -0,0 +1,37 @@
+/*
+ * Multilevel dropdown menu helpers
+ *
+ * Shared stylesheet for handling nested dropdown positioning and hover
+ * behaviour. Included automatically by \class\theme.php for all themes.
+ *
+ * To add custom rules or overrides put them in your theme's own CSS file(s)
+ * after this one; themes should not need to include this file manually.
+ *
+ * LICENSE
+ *
+ * You may not change or alter any portion of this comment or credits
+ * of supporting developers from this source code or any supporting source code
+ * which is considered copyrighted (c) material of the original comment or credit authors.
+ *
+ * @copyright (c) 2000-2026 XOOPS Project (www.xoops.org)
+ * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html)
+ */
+
+/* === multilevel dropdown support === */
+/* position nested menus to the right and keep them aligned */
+.dropdown-submenu {
+ position: relative;
+}
+.dropdown-submenu > .dropdown-menu {
+ top: 0;
+ left: 100%;
+ margin-top: -1px;
+}
+/* show on hover for desktop users (optional) */
+.dropdown-submenu:hover > .dropdown-menu {
+ display: block;
+}
+/* show when toggled by javascript (touch/mobile and click users) */
+.dropdown-submenu > .dropdown-menu.show {
+ display: block;
+}
\ No newline at end of file
diff --git a/htdocs/modules/system/include/update.php b/htdocs/modules/system/include/update.php
index f1e02fbd..5a2aca00 100644
--- a/htdocs/modules/system/include/update.php
+++ b/htdocs/modules/system/include/update.php
@@ -25,6 +25,111 @@
*/
function xoops_module_update_system(XoopsModule $module, $prev_version = null)
{
+ global $xoopsDB;
+ if ($prev_version < '2.2.0-Stable') {
+ $menusCategoryTable = $xoopsDB->prefix('menuscategory');
+ $menusItemsTable = $xoopsDB->prefix('menusitems');
+
+ $sql = "CREATE TABLE IF NOT EXISTS " . $menusCategoryTable . " (
+ category_id INT AUTO_INCREMENT PRIMARY KEY,
+ category_title VARCHAR(100) NOT NULL,
+ category_prefix TEXT,
+ category_suffix TEXT,
+ category_url VARCHAR(255) NULL,
+ category_target TINYINT(1) DEFAULT 0,
+ category_position INT DEFAULT 0,
+ category_protected INT DEFAULT 0,
+ category_active TINYINT(1) DEFAULT 1);";
+ $xoopsDB->query($sql);
+ $sql = "CREATE TABLE IF NOT EXISTS " . $menusItemsTable . " (
+ items_id INT AUTO_INCREMENT PRIMARY KEY,
+ items_pid INT NULL DEFAULT NULL,
+ items_cid INT NULL,
+ items_title VARCHAR(100) NOT NULL,
+ items_prefix TEXT,
+ items_suffix TEXT,
+ items_url VARCHAR(255) NULL,
+ items_target TINYINT(1) DEFAULT 0,
+ items_position INT DEFAULT 0,
+ items_protected INT DEFAULT 0,
+ items_active TINYINT(1) DEFAULT 1,
+ FOREIGN KEY (items_cid) REFERENCES {$menusCategoryTable}(category_id) ON DELETE CASCADE,
+ FOREIGN KEY (items_pid) REFERENCES {$menusItemsTable}(items_id) ON DELETE CASCADE);";
+ $xoopsDB->query($sql);
+ // add default data only when not initialized yet
+ $sql = 'SELECT category_id FROM ' . $xoopsDB->prefix('menuscategory') . ' WHERE category_id = 1';
+ $result = $xoopsDB->query($sql);
+ if (!$xoopsDB->isResultSet($result) || 0 === $xoopsDB->getRowsNum($result)) {
+ $sql = "INSERT INTO " . $menusCategoryTable . " VALUES (1, 'MENUS_HOME', '', '', '/', 0, 0, 1, 1)";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $menusCategoryTable . " VALUES (2, 'MENUS_ADMIN', '', '', 'admin.php', 0, 10, 1, 1)";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $menusCategoryTable . " VALUES (3, 'MENUS_ACCOUNT', '', '', '', 0, 20, 1, 1)";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $menusItemsTable . " VALUES (1, NULL, 3, 'MENUS_ACCOUNT_EDIT', '', '', 'user.php', 0, 1, 1, 1)";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $menusItemsTable . " VALUES (2, NULL, 3, 'MENUS_ACCOUNT_LOGIN', '', '', 'user.php', 0, 2, 1, 1)";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $menusItemsTable . " VALUES (3, NULL, 3, 'MENUS_ACCOUNT_REGISTER', '', '', 'register.php', 0, 2, 1, 1)";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $menusItemsTable . " VALUES (4, NULL, 3, 'MENUS_ACCOUNT_MESSAGES', '', '<{xoInboxCount}>', 'viewpmsg.php', 0, 3, 1, 1)";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $menusItemsTable . " VALUES (5, NULL, 3, 'MENUS_ACCOUNT_NOTIFICATIONS', '', '', 'notifications.php', 0, 4, 1, 1)";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $menusItemsTable . " VALUES (6, NULL, 3, 'MENUS_ACCOUNT_TOOLBAR', '', '', 'javascript:xswatchToolbarToggle();', 0, 5, 1, 1)";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $menusItemsTable . " VALUES (7, NULL, 3, 'MENUS_ACCOUNT_LOGOUT', '', '', 'user.php?op=logout', 0, 5, 1, 1)";
+ $xoopsDB->query($sql);
+ // add permissions for category and items
+ // MENUS_HOME
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 1, 1, 1, 'menus_category_view')";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 2, 1, 1, 'menus_category_view')";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 3, 1, 1, 'menus_category_view')";
+ $xoopsDB->query($sql);
+ // MENUS_ADMIN
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 1, 2, 1, 'menus_category_view')";
+ $xoopsDB->query($sql);
+ // MENUS_ACCOUNT
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 1, 3, 1, 'menus_category_view')";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 2, 3, 1, 'menus_category_view')";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 3, 3, 1, 'menus_category_view')";
+ $xoopsDB->query($sql);
+ // MENUS_ACCOUNT_EDIT
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 1, 1, 1, 'menus_items_view')";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 2, 1, 1, 'menus_items_view')";
+ $xoopsDB->query($sql);
+ // MENUS_ACCOUNT_LOGIN
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 3, 2, 1, 'menus_items_view')";
+ $xoopsDB->query($sql);
+ // MENUS_ACCOUNT_REGISTER
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 3, 3, 1, 'menus_items_view')";
+ $xoopsDB->query($sql);
+ // MENUS_ACCOUNT_MESSAGES
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 1, 4, 1, 'menus_items_view')";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 2, 4, 1, 'menus_items_view')";
+ $xoopsDB->query($sql);
+ // MENUS_ACCOUNT_NOTIFICATIONS
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 1, 5, 1, 'menus_items_view')";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 2, 5, 1, 'menus_items_view')";
+ $xoopsDB->query($sql);
+ // MENUS_ACCOUNT_TOOLBAR
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 1, 6, 1, 'menus_items_view')";
+ $xoopsDB->query($sql);
+ // MENUS_ACCOUNT_LOGOUT
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 1, 7, 1, 'menus_items_view')";
+ $xoopsDB->query($sql);
+ $sql = "INSERT INTO " . $xoopsDB->prefix('group_permission') . " VALUES (NULL, 2, 7, 1, 'menus_items_view')";
+ $xoopsDB->query($sql);
+ }
+ }
+
// irmtfan bug fix: solve templates duplicate issue
$ret = null;
if ($prev_version < '2.1.1') {
diff --git a/htdocs/modules/system/js/menus.js b/htdocs/modules/system/js/menus.js
new file mode 100644
index 00000000..8f5ca197
--- /dev/null
+++ b/htdocs/modules/system/js/menus.js
@@ -0,0 +1,222 @@
+jQuery(function($){
+ 'use strict';
+
+ // utilitaires
+ function getTokenData() {
+ var $tokenInput = $('#menus-token').find('input').first();
+ var data = {};
+ if ($tokenInput.length) {
+ data[$tokenInput.attr('name')] = $tokenInput.val();
+ data['XOOPS_TOKEN_REQUEST'] = $tokenInput.val(); // fallback
+ }
+ return data;
+ }
+
+ function updateTokenFromResponse(resp) {
+ if (resp && resp.token) {
+ $('#menus-token').html(resp.token);
+ }
+ }
+
+ var labelsCfg = (window.XOOPS_MENUS && window.XOOPS_MENUS.labels) || {};
+ var LABEL_YES = labelsCfg.activeYes || 'Yes';
+ var LABEL_NO = labelsCfg.activeNo || 'No';
+
+ function parseJsonSafely(rawResponse) {
+ if (rawResponse && typeof rawResponse === 'object') {
+ return rawResponse;
+ }
+ var text = $.trim(String(rawResponse || ''));
+ if (!text) {
+ return null;
+ }
+ try {
+ return JSON.parse(text);
+ } catch (e) {
+ // Some environments may prepend notices/HTML before JSON.
+ var start = text.indexOf('{');
+ var end = text.lastIndexOf('}');
+ if (start !== -1 && end > start) {
+ try {
+ return JSON.parse(text.substring(start, end + 1));
+ } catch (e2) {
+ return null;
+ }
+ }
+ return null;
+ }
+ }
+
+ function ajaxJsonPost(url, data, onSuccess) {
+ return $.ajax({
+ url: url,
+ method: 'POST',
+ data: data,
+ dataType: 'text'
+ }).done(function(rawResponse){
+ var response = parseJsonSafely(rawResponse);
+ if (!response) {
+ console.error('Ajax JSON parse error:', rawResponse);
+ alert('Ajax error: invalid JSON response (see console)');
+ return;
+ }
+ updateTokenFromResponse(response);
+ if (typeof onSuccess === 'function') onSuccess(response);
+ }).fail(function(jqXHR, textStatus, errorThrown){
+ console.error('Ajax error:', textStatus, errorThrown, jqXHR.responseText);
+ alert('Ajax error (see console)');
+ });
+ }
+
+ // SORTABLE
+ if ($.fn.sortable) {
+ $('#menus-row').sortable({
+ items: '[data-id]',
+ placeholder: 'card-placeholder',
+ tolerance: 'pointer',
+ forcePlaceholderSize: true,
+ helper: function(e, ui) {
+ var $clone = ui.clone();
+ $clone.css({ 'width': ui.outerWidth(), 'box-sizing': 'border-box' }).appendTo('body');
+ return $clone;
+ },
+ appendTo: 'body',
+ start: function(evt, ui) {
+ ui.placeholder.height(ui.helper.outerHeight());
+ ui.placeholder.width(ui.helper.outerWidth());
+ ui.helper.css('z-index', 1200);
+ },
+ update: function() {
+ var ids = $('#menus-row').children('[data-id]').map(function(){ return $(this).data('id'); }).get();
+ var data = $.extend({ order: ids }, getTokenData());
+ ajaxJsonPost('admin.php?fct=menus&op=saveorder', data, function(response){
+ if (!(response && response.success)) {
+ alert(response && response.message ? response.message : 'Save failed');
+ }
+ });
+ }
+ }).disableSelection();
+ } else {
+ console.warn('jQuery UI sortable not found.');
+ }
+
+ // helper to check ancestors for disabled state
+ function hasInactiveAncestor($li) {
+ var pid = parseInt($li.data('pid'), 10) || 0;
+ while (pid) {
+ var $parentBadge = $('.item-active-toggle[data-id="' + pid + '"]');
+ if ($parentBadge.length) {
+ if (parseInt($parentBadge.attr('data-active'), 10) === 0) {
+ return true;
+ }
+ pid = parseInt($parentBadge.closest('li.list-group-item').data('pid'), 10) || 0;
+ } else {
+ break;
+ }
+ }
+ return false;
+ }
+
+ // helper to update og row visuals depending on state
+ function updateRowState($elem, state) {
+ var $row = $elem.closest('li.list-group-item, .card');
+ if ($row.length) {
+ if (state) {
+ $row.removeClass('text-muted inactive');
+ // enable action buttons in this row if present
+ $row.find('.btn-group .btn').removeClass('disabled').removeAttr('aria-disabled').css('pointer-events', '');
+ } else {
+ $row.addClass('text-muted inactive');
+ // disable action buttons so they cannot be clicked
+ $row.find('.btn-group .btn').addClass('disabled').attr('aria-disabled', 'true').css('pointer-events', 'none');
+ }
+ }
+ }
+
+ function refreshChildLocks() {
+ $('.item-active-toggle').each(function() {
+ var $badge = $(this);
+ var $li = $badge.closest('li.list-group-item');
+ var active = parseInt($badge.attr('data-active'), 10) ? 1 : 0;
+ updateRowState($badge, active);
+ if (hasInactiveAncestor($li)) {
+ $badge.addClass('disabled').css('cursor', 'not-allowed').attr('title', 'Parent inactive');
+ } else {
+ $badge.removeClass('disabled').css('cursor', '').removeAttr('title');
+ }
+ });
+ // also mark category cards if necessary
+ $('.category-active-toggle').each(function() {
+ var $badge = $(this);
+ var active = parseInt($badge.attr('data-active'), 10) ? 1 : 0;
+ updateRowState($badge, active);
+ });
+ }
+
+ // initial state on page load
+ refreshChildLocks();
+
+ // TOGGLE ACTIVE (categories & items) - délégation unique
+ $(document).on('click', '.category-active-toggle, .item-active-toggle', function(e){
+ e.preventDefault();
+ var $el = $(this);
+ if ($el.hasClass('disabled')) {
+ var msg = (window.XOOPS_MENUS && window.XOOPS_MENUS.messages && window.XOOPS_MENUS.messages.parentInactive) ? window.XOOPS_MENUS.messages.parentInactive : 'Parent is inactive';
+ alert(msg);
+ return;
+ }
+ var isCategory = $el.hasClass('category-active-toggle');
+ var id = $el.data('id');
+ if (!id) return;
+
+ var url = isCategory ? 'admin.php?fct=menus&op=toggleactivecat' : 'admin.php?fct=menus&op=toggleactiveitem';
+ var paramName = isCategory ? 'category_id' : 'item_id';
+ var data = {};
+ data[paramName] = id;
+ $.extend(data, getTokenData());
+
+ ajaxJsonPost(url, data, function(response){
+ if (response && response.success) {
+ var active = parseInt(response.active, 10) ? 1 : 0;
+ function updateBadge($badge, state) {
+ if (state) {
+ $badge
+ .removeClass('badge-danger')
+ .addClass('badge-success')
+ .attr('data-active', 1)
+ .attr('aria-pressed', 'true')
+ .text(LABEL_YES);
+ } else {
+ $badge
+ .removeClass('badge-success')
+ .addClass('badge-danger')
+ .attr('data-active', 0)
+ .attr('aria-pressed', 'false')
+ .text(LABEL_NO);
+ }
+ }
+
+ // update clicked element
+ updateBadge($el, active);
+ updateRowState($el, active);
+
+ // if server sent list of updated children, adjust them as well
+ if (response.updated && Array.isArray(response.updated)) {
+ response.updated.forEach(function(updatedId) {
+ var $child = $('.item-active-toggle[data-id="' + updatedId + '"]');
+ if ($child.length) {
+ updateBadge($child, active);
+ updateRowState($child, active);
+ }
+ });
+ }
+
+ // re-evaluate locks after changes
+ refreshChildLocks();
+ } else {
+ alert(response && response.message ? response.message : 'Toggle failed');
+ }
+ });
+ });
+
+});
\ No newline at end of file
diff --git a/htdocs/modules/system/js/multilevelmenu.js b/htdocs/modules/system/js/multilevelmenu.js
new file mode 100644
index 00000000..1c9b5ef2
--- /dev/null
+++ b/htdocs/modules/system/js/multilevelmenu.js
@@ -0,0 +1,20 @@
+/*
+ * JavaScript helpers for multilevel dropdown menus
+ *
+ * Shared file included automatically by class/theme.php for every theme.
+ * Contains behaviour previously embedded inline in individual theme templates.
+ *
+ * Licensed under GNU GPL 2.0 or later (see LICENSE in root).
+ */
+
+// toggle submenus inside multilevel dropdowns
+document.addEventListener('DOMContentLoaded', function() {
+ document.querySelectorAll('.dropdown-submenu > a').forEach(function(el) {
+ el.addEventListener('click', function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ var sub = this.nextElementSibling;
+ if (sub) sub.classList.toggle('show');
+ });
+ });
+});
diff --git a/htdocs/modules/system/language/english/admin.php b/htdocs/modules/system/language/english/admin.php
index 77c39a07..119a720c 100644
--- a/htdocs/modules/system/language/english/admin.php
+++ b/htdocs/modules/system/language/english/admin.php
@@ -74,3 +74,8 @@
//2.5.7
define('_AM_SYSTEM_USAGE', 'Usage');
define('_AM_SYSTEM_ACTIVE', 'Active');
+
+
+//2.7.0
+define('_AM_SYSTEM_MENUS', 'Menus');
+define('_AM_SYSTEM_MENUS_DESC', 'Menu manager');
\ No newline at end of file
diff --git a/htdocs/modules/system/language/english/admin/menus.php b/htdocs/modules/system/language/english/admin/menus.php
new file mode 100644
index 00000000..1e3b41d9
--- /dev/null
+++ b/htdocs/modules/system/language/english/admin/menus.php
@@ -0,0 +1,85 @@
+
+
<{$pref.text}>
- + - <{/foreach}> + <{/foreach}>