diff --git a/css/site-structure-menu.css b/css/site-structure-menu.css new file mode 100644 index 0000000..b0f3521 --- /dev/null +++ b/css/site-structure-menu.css @@ -0,0 +1,63 @@ +/** + * @file + * Styles for the site structure menu block. + */ + +.site-structure-menu { + font-size: 0.9rem; +} + +.site-structure-menu__title { + font-size: 1.1rem; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #ddd; +} + +.site-structure-menu__title a { + text-decoration: none; + color: inherit; +} + +.site-structure-menu__title a:hover { + text-decoration: underline; +} + +.site-structure-menu__list { + list-style: none; + margin: 0; + padding: 0; +} + +.site-structure-menu__list--level-2, +.site-structure-menu__list--level-3, +.site-structure-menu__list--level-4 { + padding-left: 1rem; +} + +.site-structure-menu__item { + margin: 0.25rem 0; +} + +.site-structure-menu__link { + display: block; + padding: 0.25rem 0.5rem; + text-decoration: none; + color: #333; + border-radius: 3px; + transition: background-color 0.15s ease; +} + +.site-structure-menu__link:hover { + background-color: #f5f5f5; + text-decoration: none; +} + +.site-structure-menu__link--active-trail { + font-weight: 600; + color: #0073bd; +} + +.site-structure-menu__item--active-trail > .site-structure-menu__link { + background-color: #e8f4fc; +} diff --git a/site_structure.libraries.yml b/site_structure.libraries.yml new file mode 100644 index 0000000..56b1cc6 --- /dev/null +++ b/site_structure.libraries.yml @@ -0,0 +1,5 @@ +menu: + version: VERSION + css: + component: + css/site-structure-menu.css: {} diff --git a/site_structure.module b/site_structure.module index 0b362d9..7310e6b 100644 --- a/site_structure.module +++ b/site_structure.module @@ -191,6 +191,29 @@ function _site_structure_creates_circular_reference(int|string $node_id, int|str return FALSE; } +/** + * Implements hook_theme(). + */ +function site_structure_theme(): array { + return [ + 'site_structure_menu' => [ + 'variables' => [ + 'ancestor' => NULL, + 'tree' => [], + 'active_trail' => [], + 'show_ancestor_title' => TRUE, + ], + ], + 'site_structure_menu_tree' => [ + 'variables' => [ + 'items' => [], + 'active_trail' => [], + 'depth' => 0, + ], + ], + ]; +} + /** * Implements hook_token_info(). */ diff --git a/src/Plugin/Block/SiteStructureMenuBlock.php b/src/Plugin/Block/SiteStructureMenuBlock.php new file mode 100644 index 0000000..b116b54 --- /dev/null +++ b/src/Plugin/Block/SiteStructureMenuBlock.php @@ -0,0 +1,451 @@ +entityTypeManager = $entity_type_manager; + $this->routeMatch = $route_match; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity_type.manager'), + $container->get('current_route_match'), + ); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration(): array { + return [ + 'max_depth' => 3, + 'show_ancestor_title' => TRUE, + 'expand_active_trail' => TRUE, + ] + parent::defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function blockForm($form, FormStateInterface $form_state): array { + $form = parent::blockForm($form, $form_state); + + $form['max_depth'] = [ + '#type' => 'number', + '#title' => $this->t('Maximum depth'), + '#description' => $this->t('Maximum number of levels to display.'), + '#default_value' => $this->configuration['max_depth'], + '#min' => 1, + '#max' => self::MAX_DEPTH, + ]; + + $form['show_ancestor_title'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Show ancestor title'), + '#description' => $this->t('Display the ancestor entity title as menu header.'), + '#default_value' => $this->configuration['show_ancestor_title'], + ]; + + $form['expand_active_trail'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Expand active trail'), + '#description' => $this->t('Automatically expand menu items in the active trail.'), + '#default_value' => $this->configuration['expand_active_trail'], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function blockSubmit($form, FormStateInterface $form_state): void { + $this->configuration['max_depth'] = $form_state->getValue('max_depth'); + $this->configuration['show_ancestor_title'] = $form_state->getValue('show_ancestor_title'); + $this->configuration['expand_active_trail'] = $form_state->getValue('expand_active_trail'); + } + + /** + * {@inheritdoc} + */ + public function build(): array { + $this->cacheTags = []; + + // Get current context. + $current_node = $this->getCurrentNode(); + $ancestor = $this->findAncestor($current_node); + + if (!$ancestor) { + return []; + } + + $this->cacheTags[] = $ancestor->getEntityTypeId() . ':' . $ancestor->id(); + + // Build menu tree. + $tree = $this->buildTree($ancestor); + + if (empty($tree)) { + return []; + } + + // Determine active trail. + $active_trail = []; + if ($current_node && $this->configuration['expand_active_trail']) { + $active_trail = $this->getActiveTrail($current_node); + } + + $build = [ + '#theme' => 'site_structure_menu', + '#ancestor' => $ancestor, + '#tree' => $tree, + '#active_trail' => $active_trail, + '#show_ancestor_title' => $this->configuration['show_ancestor_title'], + '#cache' => [ + 'tags' => $this->cacheTags, + 'contexts' => ['route', 'url.path'], + ], + ]; + + return $build; + } + + /** + * Gets the current node from context or route. + * + * @return \Drupal\node\NodeInterface|null + * The current node or NULL. + */ + protected function getCurrentNode(): ?NodeInterface { + // Try to get from block context first. + try { + $node = $this->getContextValue('node'); + if ($node instanceof NodeInterface) { + return $node; + } + } + catch (\Exception $e) { + // Context not available. + } + + // Fall back to route. + $node = $this->routeMatch->getParameter('node'); + return $node instanceof NodeInterface ? $node : NULL; + } + + /** + * Finds the ancestor entity for the given node. + * + * @param \Drupal\node\NodeInterface|null $node + * The node to find ancestor for. + * + * @return \Drupal\Core\Entity\EntityInterface|null + * The ancestor entity (term, user, or group) or NULL. + */ + protected function findAncestor(?NodeInterface $node): ?EntityInterface { + if (!$node) { + // Check if we're on a taxonomy term, user, or group page. + return $this->getAncestorFromRoute(); + } + + if (!$node->hasField('field_parent_page')) { + return NULL; + } + + $visited = []; + $current = $node; + $max_iterations = self::MAX_DEPTH; + + while ($current && $max_iterations > 0) { + $max_iterations--; + + if (isset($visited[$current->id()])) { + break; + } + $visited[$current->id()] = TRUE; + + if (!$current->hasField('field_parent_page') || $current->get('field_parent_page')->isEmpty()) { + // Node without parent - check if it has a direct section. + if ($current->hasField('field_site_section') && !$current->get('field_site_section')->isEmpty()) { + return $current->get('field_site_section')->entity; + } + return NULL; + } + + $parent_field = $current->get('field_parent_page')->first(); + $parent_type = $parent_field->target_type ?? NULL; + $parent_id = $parent_field->target_id ?? NULL; + + if (!$parent_type || !$parent_id) { + break; + } + + // If parent is not a node, it's our ancestor. + if ($parent_type !== 'node') { + $storage = $this->entityTypeManager->getStorage($parent_type); + return $storage->load($parent_id); + } + + // Parent is a node, continue traversing. + $current = $this->entityTypeManager->getStorage('node')->load($parent_id); + } + + return NULL; + } + + /** + * Gets ancestor entity from current route (term, user, or group page). + * + * @return \Drupal\Core\Entity\EntityInterface|null + * The ancestor entity or NULL. + */ + protected function getAncestorFromRoute(): ?EntityInterface { + // Check taxonomy term. + $term = $this->routeMatch->getParameter('taxonomy_term'); + if ($term instanceof TermInterface && $term->bundle() === 'site_section') { + return $term; + } + + // Check user. + $user = $this->routeMatch->getParameter('user'); + if ($user instanceof UserInterface) { + return $user; + } + + // Check group (if module exists). + $group = $this->routeMatch->getParameter('group'); + if ($group instanceof GroupInterface) { + return $group; + } + + return NULL; + } + + /** + * Builds the menu tree for an ancestor. + * + * @param \Drupal\Core\Entity\EntityInterface $ancestor + * The ancestor entity. + * + * @return array + * The menu tree structure. + */ + protected function buildTree(EntityInterface $ancestor): array { + $root_pages = $this->getChildPages($ancestor->getEntityTypeId(), (string) $ancestor->id()); + + if (empty($root_pages)) { + return []; + } + + $tree = []; + $max_depth = (int) $this->configuration['max_depth']; + + foreach ($root_pages as $page) { + $tree[] = $this->buildBranch($page, 1, $max_depth); + } + + return $tree; + } + + /** + * Builds a branch of the menu tree recursively. + * + * @param \Drupal\node\NodeInterface $node + * The node for this branch. + * @param int $current_depth + * The current depth level. + * @param int $max_depth + * The maximum depth to traverse. + * + * @return array + * The branch structure. + */ + protected function buildBranch(NodeInterface $node, int $current_depth, int $max_depth): array { + $this->cacheTags[] = 'node:' . $node->id(); + + $branch = [ + 'entity' => $node, + 'id' => $node->id(), + 'title' => $node->label(), + 'url' => $node->toUrl()->toString(), + 'depth' => $current_depth, + 'children' => [], + ]; + + if ($current_depth < $max_depth) { + $children = $this->getChildPages('node', (string) $node->id()); + foreach ($children as $child) { + $branch['children'][] = $this->buildBranch($child, $current_depth + 1, $max_depth); + } + } + + return $branch; + } + + /** + * Gets child content pages for a given parent entity. + * + * @param string $parent_type + * The parent entity type. + * @param string $parent_id + * The parent entity ID. + * + * @return \Drupal\node\NodeInterface[] + * Array of child nodes. + */ + protected function getChildPages(string $parent_type, string $parent_id): array { + $query = $this->entityTypeManager->getStorage('node')->getQuery() + ->accessCheck(TRUE) + ->condition('type', 'content_page') + ->condition('status', 1) + ->condition('field_parent_page.target_type', $parent_type) + ->condition('field_parent_page.target_id', $parent_id) + ->sort('title', 'ASC'); + + $nids = $query->execute(); + + if (empty($nids)) { + return []; + } + + $nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($nids); + + // Add list cache tag for content pages. + $this->cacheTags[] = 'node_list:content_page'; + + return array_values($nodes); + } + + /** + * Gets the active trail (IDs of nodes from current to root). + * + * @param \Drupal\node\NodeInterface $node + * The current node. + * + * @return array + * Array of node IDs in the active trail. + */ + protected function getActiveTrail(NodeInterface $node): array { + $trail = [$node->id()]; + $visited = [$node->id() => TRUE]; + $current = $node; + $max_iterations = self::MAX_DEPTH; + + while ($max_iterations > 0) { + $max_iterations--; + + if (!$current->hasField('field_parent_page') || $current->get('field_parent_page')->isEmpty()) { + break; + } + + $parent_field = $current->get('field_parent_page')->first(); + $parent_type = $parent_field->target_type ?? NULL; + $parent_id = $parent_field->target_id ?? NULL; + + // Stop if parent is not a node (reached ancestor). + if ($parent_type !== 'node' || !$parent_id) { + break; + } + + if (isset($visited[$parent_id])) { + break; + } + $visited[$parent_id] = TRUE; + + $trail[] = $parent_id; + $current = $this->entityTypeManager->getStorage('node')->load($parent_id); + + if (!$current) { + break; + } + } + + return $trail; + } + + /** + * {@inheritdoc} + */ + public function getCacheTags(): array { + return Cache::mergeTags(parent::getCacheTags(), ['node_list:content_page']); + } + + /** + * {@inheritdoc} + */ + public function getCacheContexts(): array { + return Cache::mergeContexts(parent::getCacheContexts(), ['route', 'url.path']); + } + +} diff --git a/templates/site-structure-menu.html.twig b/templates/site-structure-menu.html.twig new file mode 100644 index 0000000..286d090 --- /dev/null +++ b/templates/site-structure-menu.html.twig @@ -0,0 +1,63 @@ +{# +/** + * @file + * Template for the site structure menu block. + * + * Available variables: + * - ancestor: The ancestor entity (term, user, or group). + * - tree: Array of menu tree items, each containing: + * - id: Node ID. + * - title: Node title. + * - url: Node URL. + * - depth: Current depth level. + * - children: Array of child items. + * - entity: The node entity. + * - active_trail: Array of node IDs in the active trail. + * - show_ancestor_title: Whether to show the ancestor title as header. + */ +#} +{{ attach_library('site_structure/menu') }} +{% if tree is not empty %} + +{% endif %} + +{% macro menu_item(item, active_trail, depth) %} + {% set is_active = item.id in active_trail %} + {% set has_children = item.children is not empty %} + {% set classes = [ + 'site-structure-menu__item', + 'site-structure-menu__item--level-' ~ depth, + is_active ? 'site-structure-menu__item--active-trail', + has_children ? 'site-structure-menu__item--has-children', + ] %} + + + + {{- item.title -}} + + + {% if has_children %} + + {% endif %} + +{% endmacro %} diff --git a/translations/pt-br.po b/translations/pt-br.po index b5a784b..47a62a5 100644 --- a/translations/pt-br.po +++ b/translations/pt-br.po @@ -304,3 +304,35 @@ msgstr "Biblioteca" msgid "IT Services" msgstr "Informática" + +#: src/Plugin/Block/SiteStructureMenuBlock.php +msgid "Site Structure Menu" +msgstr "Menu da Estrutura do Site" + +#: src/Plugin/Block/SiteStructureMenuBlock.php +msgid "Maximum depth" +msgstr "Profundidade máxima" + +#: src/Plugin/Block/SiteStructureMenuBlock.php +msgid "Maximum number of levels to display." +msgstr "Número máximo de níveis a exibir." + +#: src/Plugin/Block/SiteStructureMenuBlock.php +msgid "Show ancestor title" +msgstr "Exibir título do ancestral" + +#: src/Plugin/Block/SiteStructureMenuBlock.php +msgid "Display the ancestor entity title as menu header." +msgstr "Exibir o título da entidade ancestral como cabeçalho do menu." + +#: src/Plugin/Block/SiteStructureMenuBlock.php +msgid "Expand active trail" +msgstr "Expandir trilha ativa" + +#: src/Plugin/Block/SiteStructureMenuBlock.php +msgid "Automatically expand menu items in the active trail." +msgstr "Expandir automaticamente os itens de menu na trilha ativa." + +#: templates/site-structure-menu.html.twig +msgid "Site structure navigation" +msgstr "Navegação da estrutura do site"