├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config ├── packages │ └── translation.yml └── services.yaml ├── src ├── Controller │ ├── EditController.php │ ├── ListController.php │ └── SearchController.php ├── Extension.php ├── ExtensionMenu.php └── Utils │ ├── LinkHydrator.php │ ├── NameHelper.php │ └── Search │ ├── NavigationContentSearch.php │ └── NavigationSearchResult.php ├── templates ├── edit.html.twig └── index.html.twig ├── translations ├── navigations_ui.de.xlf ├── navigations_ui.en.xlf ├── navigations_ui.fr.xlf └── navigations_ui.nl.xlf └── usage-example.gif /.gitignore: -------------------------------------------------------------------------------- 1 | ### Platfom-specific files 2 | .DS_Store 3 | thumbs.db 4 | Vagrantfile 5 | .vagrant* 6 | .idea 7 | .vscode/* 8 | appveyor.yml 9 | 10 | ### Local files 11 | vendor/ 12 | composer.lock 13 | var/ 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Eckinox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bolt Navigations UI 2 | 3 | A Bolt CMS extension that adds a visual interface to update menus in Bolt's admin dashboard. 4 | 5 | It allows end-users to update menus intuitively via a drag-and-drop interface, and to add links with a simple content search engine. 6 | 7 | ![Usage example](usage-example.gif) 8 | 9 | --- 10 | 11 | ## Installation 12 | 13 | To get started, first make sure you're running PHP 7.2.9 or higher, as well as Bolt 5 or higher. 14 | 15 | Then, in your project, install the extension via Composer: 16 | 17 | ```bash 18 | composer require eckinox/bolt-navigation-ui 19 | ``` 20 | 21 | Once that's done, you're all set: you can now edit your menus via the new visual interface! 22 | 23 | --- 24 | 25 | ## License 26 | 27 | This extension is distributed by [Eckinox](https://www.eckinox.ca/) with an MIT license. 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eckinox/bolt-navigation-ui", 3 | "description": "A Bolt 5 extension that provides a user-friendly interface to manage navigations, straight from the admin section.", 4 | "type": "bolt-extension", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Émile Perron", 9 | "email": "contact@emileperron.com" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=7.2.9", 14 | "symfony/yaml": "^3.4 || ^4.4 || ^5.1 || ^6.0", 15 | "symfony/string": "*" 16 | }, 17 | "autoload": { 18 | "psr-4": { 19 | "Eckinox\\BoltNavigationUI\\": "src/" 20 | } 21 | }, 22 | "minimum-stability": "dev", 23 | "prefer-stable": false, 24 | "extra": { 25 | "entrypoint": "Eckinox\\BoltNavigationUI\\Extension" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /config/packages/translation.yml: -------------------------------------------------------------------------------- 1 | framework: 2 | default_locale: '%locale%' 3 | translator: 4 | fallbacks: 5 | - 'en' 6 | 7 | # php_translation 8 | translation: 9 | webui: 10 | enabled: true 11 | edit_in_place: 12 | enabled: false 13 | configs: 14 | bolt: 15 | dirs: ["%kernel.project_dir%/templates", "%kernel.project_dir%/src"] 16 | output_dir: "%kernel.project_dir%/translations" 17 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | Eckinox\BoltNavigationUI\ExtensionMenu: 3 | class: Eckinox\BoltNavigationUI\ExtensionMenu 4 | arguments: [ '@Symfony\Component\Routing\Generator\UrlGeneratorInterface', '@Symfony\Contracts\Translation\TranslatorInterface' ] 5 | tags: [ 'bolt.extension_backend_menu' ] 6 | 7 | Eckinox\BoltNavigationUI\Utils\Search\NavigationContentSearch: 8 | arguments: 9 | - '@Bolt\Configuration\Config' 10 | - '@Bolt\Repository\ContentRepository' 11 | - '@Bolt\Twig\ContentExtension' 12 | - '@Symfony\Component\Routing\RouterInterface' 13 | - '@Symfony\Component\String\Slugger\SluggerInterface' 14 | - '%locale%' 15 | 16 | framework: 17 | translator: 18 | paths: 19 | - '%kernel.project_dir%/vendor/eckinox/bolt-navigation-ui/translations' -------------------------------------------------------------------------------- /src/Controller/EditController.php: -------------------------------------------------------------------------------- 1 | render('@bolt-navigation-ui/edit.html.twig', [ 19 | "menuName" => $name, 20 | "cleanName" => $nameHelper->makeNameHumanFriendly($name), 21 | "linkHydrator" => $linkHydrator, 22 | ]); 23 | } 24 | 25 | public function save(Request $request, Config $config): Response 26 | { 27 | $name = $request->request->get("name"); 28 | $menusConfig = $config->get("menu")->toArray(); 29 | $menusConfigPath = $config->getPath("config/bolt/menu"); 30 | $encodedNavigationData = $request->request->get("encodedConfig"); 31 | $navigationData = json_decode($encodedNavigationData, true); 32 | 33 | // Update the navigation in the config array 34 | $menusConfig[$name] = $navigationData; 35 | 36 | // Encode the configuration array to Yaml 37 | $updatedYamlConfig = Yaml::dump($menusConfig, 20); 38 | 39 | if (!$updatedYamlConfig) { 40 | return new JsonResponse(["success" => false, "msg" => "Sorry, an error occured while generating your menu's new configuration."]); 41 | } 42 | 43 | // Validate file name 44 | if (file_exists($menusConfigPath . ".yaml")) { 45 | $menusConfigPath = $menusConfigPath . ".yaml"; 46 | } else if (file_exists($menusConfigPath . ".yml")) { 47 | $menusConfigPath = $menusConfigPath . ".yml"; 48 | } else { 49 | return new JsonResponse(["success" => false, "msg" => "Your menu configuration file could not be found. Only Yaml menu files are supported at the moment."]); 50 | } 51 | 52 | // Save the configuration to the file 53 | file_put_contents($menusConfigPath, $updatedYamlConfig); 54 | 55 | return new JsonResponse(["success" => true, "msg" => "Your navigation has been updated successfully!"]); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Controller/ListController.php: -------------------------------------------------------------------------------- 1 | get("menu"); 15 | $menus = []; 16 | 17 | foreach ($rawMenus as $name => $items) { 18 | $menus[$name] = $nameHelper->makeNameHumanFriendly($name); 19 | } 20 | 21 | return $this->render('@bolt-navigation-ui/index.html.twig', [ 22 | "menus" => $menus, 23 | ]); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Controller/SearchController.php: -------------------------------------------------------------------------------- 1 | request->get("query"); 16 | $results = $navigationContentSearch->search($query); 17 | 18 | return new JsonResponse([ 19 | "records" => $results 20 | ]); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/Extension.php: -------------------------------------------------------------------------------- 1 | addTwigNamespace('bolt-navigation-ui'); 18 | } 19 | 20 | public function getRoutes(): array 21 | { 22 | return [ 23 | 'bolt_eckinox_navigation_list' => new Route( 24 | '/bolt/navigations', 25 | ['_controller' => 'Eckinox\BoltNavigationUI\Controller\ListController::list'] 26 | ), 27 | 'bolt_eckinox_navigation_save' => new Route( 28 | '/bolt/navigation/save', 29 | ['_controller' => 'Eckinox\BoltNavigationUI\Controller\EditController::save'], 30 | ), 31 | 'bolt_eckinox_navigation_search' => new Route( 32 | '/bolt/navigation/content-search', 33 | ['_controller' => 'Eckinox\BoltNavigationUI\Controller\SearchController::search'], 34 | ), 35 | 'bolt_eckinox_navigation_edit' => new Route( 36 | '/bolt/navigation/{name}', 37 | ['_controller' => 'Eckinox\BoltNavigationUI\Controller\EditController::edit'], 38 | ['name' => '[a-zA-Z0-9_\\-]+'] 39 | ), 40 | ]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ExtensionMenu.php: -------------------------------------------------------------------------------- 1 | urlGenerator = $urlGenerator; 21 | $this->translator = $translator; 22 | } 23 | 24 | public function addItems(MenuItem $menu): void 25 | { 26 | $extensionName = $this->translator->trans("extension_name", [], "navigations_ui"); 27 | 28 | // This adds a new heading 29 | $menu->addChild($extensionName, [ 30 | 'extras' => [ 31 | 'name' => $extensionName, 32 | 'type' => 'separator', 33 | ] 34 | ]); 35 | 36 | // This adds the link 37 | $menu->addChild($this->translator->trans("manage_navigations", [], "navigations_ui"), [ 38 | 'uri' => $this->urlGenerator->generate('bolt_eckinox_navigation_list'), 39 | 'extras' => [ 40 | 'icon' => 'fa-bars' 41 | ] 42 | ]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Utils/LinkHydrator.php: -------------------------------------------------------------------------------- 1 | config = $config; 32 | $this->contentRepository = $contentRepository; 33 | $this->contentExtension = $contentExtension; 34 | } 35 | 36 | public function getContent(string $link): ?Content 37 | { 38 | [$contentTypeSlug, $slug] = explode('/', $link); 39 | 40 | // First, try to get it if the id is numeric. 41 | if (is_numeric($slug)) { 42 | return $this->contentRepository->findOneById((int) $slug); 43 | } 44 | 45 | /** @var ContentType $contentType */ 46 | $contentType = $this->config->getContentType($contentTypeSlug); 47 | 48 | return $this->contentRepository->findOneBySlug($slug, $contentType); 49 | } 50 | 51 | public function getContentTitle(string $link): string 52 | { 53 | $trimmedLink = trim($link, '/'); 54 | 55 | // Special case for "Homepage" 56 | if ($trimmedLink === 'homepage' || $trimmedLink === $this->config->get('general/homepage')) { 57 | return 'Home'; 58 | } 59 | 60 | // If it looks like `contenttype/slug`, get the Record. 61 | if (preg_match('/^[a-zA-Z\-\_]+\/[0-9a-zA-Z\-\_]+$/', $trimmedLink)) { 62 | $content = $this->getContent($trimmedLink); 63 | if ($content) { 64 | return $this->contentExtension->getTitle($content); 65 | } 66 | } 67 | 68 | // Otherwise trust the user. ¯\_(ツ)_/¯ 69 | return ''; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Utils/NameHelper.php: -------------------------------------------------------------------------------- 1 | config = $config; 40 | $this->contentRepository = $contentRepository; 41 | $this->contentExtension = $contentExtension; 42 | $this->router = $router; 43 | $this->slugger = $slugger; 44 | $this->defaultLocale = $defaultLocale; 45 | $this->contentTypes = $this->getContentTypes(); 46 | } 47 | 48 | /** 49 | * Searches in the content entities for every content type provided. 50 | * 51 | * @return array 52 | */ 53 | public function search(string $query): array 54 | { 55 | $recordResults = $this->searchInRecords($query); 56 | $contentTypeResults = $this->searchContentTypes($query); 57 | $manualUrlResults = $this->generateManualUrlResults($query); 58 | $results = array_merge($recordResults, $contentTypeResults, $manualUrlResults); 59 | $sortedResults = $this->sortResults($results, $query); 60 | 61 | return $sortedResults; 62 | } 63 | 64 | /** 65 | * @return array 66 | */ 67 | private function sortResults(array $results, string $query): array 68 | { 69 | usort($results, function(NavigationSearchResult $resultA, NavigationSearchResult $resultB) use ($query) { 70 | // Always put manual URLs at the end 71 | if ($resultA->type != $resultB->type && ($resultA->typeLabel == "URL" || $resultB->typeLabel == "URL")) { 72 | return $resultA->typeLabel == "URL" ? 1 : -1; 73 | } 74 | 75 | // Sort by title match first 76 | $titleAMatches = $this->checkIfContentMatchesQuery([$resultA->title], $query); 77 | $titleBMatches = $this->checkIfContentMatchesQuery([$resultB->title], $query); 78 | 79 | if ($titleAMatches != $titleBMatches) { 80 | return $titleAMatches ? -1 : 1; 81 | } 82 | 83 | // Sort by URL match next 84 | $urlAMatches = $this->checkIfContentMatchesQuery([$resultA->absoluteUrl], $query); 85 | $urlBMatches = $this->checkIfContentMatchesQuery([$resultB->absoluteUrl], $query); 86 | 87 | if ($urlAMatches != $urlBMatches) { 88 | return $urlAMatches ? -1 : 1; 89 | } 90 | 91 | return strnatcasecmp($resultA->title, $resultB->title); 92 | }); 93 | 94 | return $results; 95 | } 96 | 97 | private function getContentTypes(): Collection 98 | { 99 | return $this->config->get('contenttypes')->where('searchable', true); 100 | } 101 | 102 | /** 103 | * Searches in the content entities for every content type provided. 104 | * 105 | * @return array 106 | */ 107 | private function searchInRecords(string $query): array 108 | { 109 | $recordsPagination = $this->contentRepository->searchNaive($query, 1, 20, $this->contentTypes); 110 | $results = []; 111 | 112 | /** 113 | * @var Content $content 114 | */ 115 | foreach ($recordsPagination->getCurrentPageResults() as $content) { 116 | $locales = $content->getLocales()->all() ?: [null]; 117 | 118 | foreach ($locales as $locale) { 119 | $results[] = $this->buildResultFromContent($content, $locale); 120 | } 121 | } 122 | 123 | return $results; 124 | } 125 | 126 | /** 127 | * Searches for content types whose listing page matches the search query. 128 | * 129 | * @return array 130 | */ 131 | private function searchContentTypes(string $query): array 132 | { 133 | $results = []; 134 | 135 | foreach ($this->contentTypes as $contentType) { 136 | $matchesQuery = $this->checkIfContentMatchesQuery([$contentType["name"]], $query); 137 | 138 | if (!$matchesQuery || ($contentType["viewless_listing"] ?? false)) { 139 | continue; 140 | } 141 | 142 | $locales = $contentType["locales"]->all() ?: [null]; 143 | 144 | foreach ($locales as $locale) { 145 | $url = $contentType["slug"]; 146 | 147 | if ($locale && $locale != $this->defaultLocale) { 148 | $url = $locale . "/" . $url; 149 | $absoluteUrl = $this->router->generate("listing_locale", [ 150 | "contentTypeSlug" => $contentType["slug"], 151 | "_locale" => $locale 152 | ], RouterInterface::ABSOLUTE_URL); 153 | } else { 154 | $absoluteUrl = $this->router->generate("listing", [ 155 | "contentTypeSlug" => $contentType["slug"], 156 | ], RouterInterface::ABSOLUTE_URL); 157 | } 158 | 159 | $results[] = new NavigationSearchResult( 160 | $contentType["name"], 161 | $contentType["slug"], 162 | $contentType["name"], 163 | $url, 164 | $absoluteUrl 165 | ); 166 | } 167 | 168 | } 169 | 170 | return $results; 171 | } 172 | 173 | /** 174 | * Returns search results for a URL manually entered by the user 175 | * 176 | * @return array 177 | */ 178 | private function generateManualUrlResults(string $query): array 179 | { 180 | // Handle absolute URL (usually external) 181 | if (preg_match("~^https?://.*~", $query)) { 182 | return [ 183 | new NavigationSearchResult( 184 | preg_replace("~^https?://(?:www.?\.)?(.+?)(?:/.*)?$~", "$1", $query), 185 | "", 186 | "URL", 187 | $query, 188 | $query 189 | ) 190 | ]; 191 | } 192 | 193 | // Treat the query as an internal URL 194 | return [ 195 | new NavigationSearchResult( 196 | $query, 197 | "", 198 | "URL", 199 | $query, 200 | strpos($query, "/") === 0 ? $query : "/" . $query 201 | ) 202 | ]; 203 | } 204 | 205 | private function buildResultFromContent(Content $content, ?string $locale = null): NavigationSearchResult 206 | { 207 | if ($locale && $locale != $content->getDefaultLocale()) { 208 | $linkUrl = $locale . "/" . $content->getContentTypeSingularSlug() . "/" . $content->getSlug(); 209 | $absoluteUrl = $this->router->generate("record_locale", [ 210 | "_locale" => $locale, 211 | "slugOrId" => $content->getSlug() ?: $content->getId(), 212 | "contentTypeSlug" => $content->getContentTypeSingularSlug(), 213 | ], RouterInterface::ABSOLUTE_URL); 214 | } else { 215 | $linkUrl = $content->getContentType() . "/" . $content->getId(); 216 | $absoluteUrl = $this->router->generate("record", [ 217 | "slugOrId" => $content->getSlug() ?: $content->getId(), 218 | "contentTypeSlug" => $content->getContentTypeSingularSlug(), 219 | ], RouterInterface::ABSOLUTE_URL); 220 | } 221 | 222 | $title = $this->contentExtension->getTitle($content, $locale ?: ""); 223 | 224 | return new NavigationSearchResult( 225 | $title ?: $content->getContentTypeName(), 226 | $content->getContentType(), 227 | $content->getContentTypeSingularName(), 228 | $linkUrl, 229 | $absoluteUrl 230 | ); 231 | } 232 | 233 | private function checkIfContentMatchesQuery(array $contentStrings, string $query): bool 234 | { 235 | $mergedContentString = implode(" ", $contentStrings); 236 | $standardizedQuery = $this->slugger->slug(strtolower(trim($query))); 237 | $standardizedContent = $this->slugger->slug(strtolower(trim($mergedContentString))); 238 | 239 | return $standardizedContent->containsAny($standardizedQuery); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/Utils/Search/NavigationSearchResult.php: -------------------------------------------------------------------------------- 1 | title = $title; 26 | $this->type = $type; 27 | $this->typeLabel = $typeLabel; 28 | $this->url = $url; 29 | $this->absoluteUrl = $absoluteUrl; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /templates/edit.html.twig: -------------------------------------------------------------------------------- 1 | {% extends '@bolt/_base/layout.html.twig' %} 2 | {% import '@bolt/_macro/_macro.html.twig' as macro %} 3 | 4 | {% trans_default_domain 'navigations_ui' %} 5 | 6 | {# The 'title' and 'shoulder' blocks are the main heading of the page. #} 7 | {% block shoulder %} 8 | {% trans %}navigations{% endtrans %} 9 | {% endblock shoulder %} 10 | 11 | {% block title %} 12 | {% trans with { "%name%": cleanName } %}editing_navigation{% endtrans %} 13 | {% endblock %} 14 | 15 | {# This 'topsection' gets output _before_ the main form, allowing `dump()`, without breaking Vue #} 16 | {% block topsection %} 17 | 56 | {% endblock %} 57 | 58 | {%- macro renderItemEntry(linkHydrator, item, parentItem = null) -%} 59 | {% set boltConfigKeys = ["title", "label", "link", "class", "submenu", "uri"] %} 60 | 61 |
62 |
63 |
64 | 65 |
66 |
{{ item.get("label")|default("") }}
67 | 71 | 74 | 75 | 76 | 77 | 78 |
79 | 84 |
85 | {%- endmacro -%} 86 | 87 | {% block main %} 88 |
89 | 96 |
97 | 98 | 99 | 152 | 179 | 225 | {% endblock %} 226 | 227 | {% block aside %} 228 |
229 |
230 | 231 | {% trans %}primary_actions{% endtrans %} 232 |
233 |
234 |
235 | 239 | 243 |
244 |
245 |
246 | 299 | 430 | {% endblock %} 431 | -------------------------------------------------------------------------------- /templates/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends '@bolt/_base/layout.html.twig' %} 2 | {% import '@bolt/_macro/_macro.html.twig' as macro %} 3 | 4 | {% trans_default_domain 'navigations_ui' %} 5 | 6 | {# The 'title' and 'shoulder' blocks are the main heading of the page. #} 7 | {% block shoulder %} 8 | {% trans %}site_management{% endtrans %} 9 | {% endblock shoulder %} 10 | 11 | {% block title %} 12 | {% trans %}manage_navigations{% endtrans %} 13 | {% endblock %} 14 | 15 | {% block vue_id 'editor' %} 16 | 17 | {# This 'topsection' gets output _before_ the main form, allowing `dump()`, without breaking Vue #} 18 | {% block topsection %} 19 | 20 | {% endblock %} 21 | 22 | {% block main %} 23 |
24 | 53 |
54 | {% endblock %} 55 | 56 | {% block aside %} 57 | {% endblock %} 58 | -------------------------------------------------------------------------------- /translations/navigations_ui.de.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | extension_name 7 | Menus UI 8 | 9 | 10 | 11 | 12 | manage_navigations 13 | Menü bearbeiten 14 | 15 | 16 | 17 | 18 | site_management 19 | Seiteneinstellungen 20 | 21 | 22 | 23 | 24 | menu_name 25 | Name 26 | 27 | 28 | 29 | 30 | menu_unique_name 31 | ID 32 | 33 | 34 | 35 | 36 | actions 37 | Aktionen 38 | 39 | 40 | 41 | 42 | edit_menu 43 | Menü bearbeiten 44 | 45 | 46 | 47 | 48 | primary_actions 49 | Aktionen 50 | 51 | 52 | 53 | 54 | add_item 55 | Element hinzufügen 56 | 57 | 58 | 59 | 60 | save_changes 61 | Speichern 62 | 63 | 64 | 65 | 66 | select_page 67 | Seite oder URL auswählen 68 | 69 | 70 | 71 | 72 | editing_navigation 73 | Bearbeite Menü: %name% 74 | 75 | 76 | 77 | 78 | navigations 79 | Navigation 80 | 81 | 82 | 83 | 84 | flash_messages.live_notification 85 | Hinweis 86 | 87 | 88 | 89 | 90 | search_page_placeholder 91 | Nach bestehender Seite suchen oder URL eingeben 92 | 93 | 94 | 95 | 96 | add_to_navigation 97 | Zum Menü hinzufügen 98 | 99 | 100 | 101 | 102 | page_search_empty_state 103 | Es konnte keine Seite zum angegebenen Stichwort gefunden werden. 104 | 105 | 106 | 107 | 108 | page_search_request_failed 109 | Es ist ein Fehler während der Suche aufgetreten. 110 | 111 | 112 | 113 | 114 | remove_item_confirmation 115 | Soll der Eintrag "%item%" wirklich entfernt werden? 116 | 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /translations/navigations_ui.en.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | extension_name 7 | Menus UI 8 | 9 | 10 | 11 | 12 | manage_navigations 13 | Manage your menus 14 | 15 | 16 | 17 | 18 | site_management 19 | Site management 20 | 21 | 22 | 23 | 24 | menu_name 25 | Name 26 | 27 | 28 | 29 | 30 | menu_unique_name 31 | Identifier 32 | 33 | 34 | 35 | 36 | actions 37 | Actions 38 | 39 | 40 | 41 | 42 | edit_menu 43 | Edit this menu 44 | 45 | 46 | 47 | 48 | primary_actions 49 | Primary actions 50 | 51 | 52 | 53 | 54 | add_item 55 | Add an element 56 | 57 | 58 | 59 | 60 | save_changes 61 | Save changes 62 | 63 | 64 | 65 | 66 | select_page 67 | Select a page or URL to add 68 | 69 | 70 | 71 | 72 | editing_navigation 73 | Editing menu: %name% 74 | 75 | 76 | 77 | 78 | navigations 79 | Navigations 80 | 81 | 82 | 83 | 84 | flash_messages.live_notification 85 | Notification 86 | 87 | 88 | 89 | 90 | search_page_placeholder 91 | Search a page or enter a URL 92 | 93 | 94 | 95 | 96 | add_to_navigation 97 | Add to menu 98 | 99 | 100 | 101 | 102 | page_search_empty_state 103 | Sorry, we couldn't find any page matching your search query. 104 | 105 | 106 | 107 | 108 | page_search_request_failed 109 | An error has occured while searching for your desired page. Please contact your website's developer for more information. 110 | 111 | 112 | 113 | 114 | remove_item_confirmation 115 | Are you sure you want to remove "%item%" from this menu? 116 | 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /translations/navigations_ui.fr.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | extension_name 7 | Menus UI 8 | 9 | 10 | 11 | 12 | manage_navigations 13 | Gérer les menus 14 | 15 | 16 | 17 | 18 | site_management 19 | Gestion du site 20 | 21 | 22 | 23 | 24 | menu_name 25 | Nom 26 | 27 | 28 | 29 | 30 | menu_unique_name 31 | Identifiant 32 | 33 | 34 | 35 | 36 | actions 37 | Actions 38 | 39 | 40 | 41 | 42 | edit_menu 43 | Modifier ce menu 44 | 45 | 46 | 47 | 48 | primary_actions 49 | Actions principales 50 | 51 | 52 | 53 | 54 | add_item 55 | Ajouter un élément 56 | 57 | 58 | 59 | 60 | save_changes 61 | Enregistrer 62 | 63 | 64 | 65 | 66 | select_page 67 | Sélectionnez une page ou une URL à ajouter 68 | 69 | 70 | 71 | 72 | editing_navigation 73 | Modifier le menu: %name% 74 | 75 | 76 | 77 | 78 | navigations 79 | Menus 80 | 81 | 82 | 83 | 84 | flash_messages.live_notification 85 | Notification 86 | 87 | 88 | 89 | 90 | search_page_placeholder 91 | Cherchez une page ou tapez une URL 92 | 93 | 94 | 95 | 96 | add_to_navigation 97 | Ajouter au menu 98 | 99 | 100 | 101 | 102 | page_search_empty_state 103 | Désolé, aucune page ne correpond à votre recherche. 104 | 105 | 106 | 107 | 108 | page_search_request_failed 109 | Une erreur s'est produite lors de la recherche de page. Vous pouvez contacter le ou la développeur(e) de votre site web pour plus d'informations. 110 | 111 | 112 | 113 | 114 | remove_item_confirmation 115 | Êtes-vous certain(e) de vouloir retirer "%item%" de ce menu? 116 | 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /translations/navigations_ui.nl.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | extension_name 7 | Extensies 8 | 9 | 10 | 11 | 12 | manage_navigations 13 | Navigatie beheren 14 | 15 | 16 | 17 | 18 | site_management 19 | Website beheren 20 | 21 | 22 | 23 | 24 | menu_name 25 | Menu naam 26 | 27 | 28 | 29 | 30 | menu_unique_name 31 | Unieke naam 32 | 33 | 34 | 35 | 36 | actions 37 | Acties 38 | 39 | 40 | 41 | 42 | edit_menu 43 | Bewerken 44 | 45 | 46 | 47 | 48 | primary_actions 49 | Acties 50 | 51 | 52 | 53 | 54 | add_item 55 | Toevoegen 56 | 57 | 58 | 59 | 60 | save_changes 61 | Opslaan 62 | 63 | 64 | 65 | 66 | flash_messages.live_notification 67 | Notificatie 68 | 69 | 70 | 71 | 72 | select_page 73 | Pagina 74 | 75 | 76 | 77 | 78 | search_page_placeholder 79 | Zoek een pagina 80 | 81 | 82 | 83 | 84 | add_to_navigation 85 | Toevoegen aan navigatie 86 | 87 | 88 | 89 | 90 | page_search_empty_state 91 | -- Geen -- 92 | 93 | 94 | 95 | 96 | page_search_request_failed 97 | Geen resultaten 98 | 99 | 100 | 101 | 102 | editing_navigation 103 | Navigatie bewerken 104 | 105 | 106 | 107 | 108 | navigations 109 | Navigaties 110 | 111 | 112 | 113 | 114 | remove_item_confirmation 115 | Weet je zeker dat je het wil verwijderen? 116 | 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /usage-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eckinox/bolt-navigation-ui/3546c99eb1ad066427b421587f28d1341b21a5aa/usage-example.gif --------------------------------------------------------------------------------