;
diff --git a/modules/ui-module/src/main/resources/manager/actions/menu/manage-menus.js b/modules/ui-module/src/main/resources/manager/actions/menu/manage-menus.js
new file mode 100644
index 000000000..7276ac27a
--- /dev/null
+++ b/modules/ui-module/src/main/resources/manager/actions/menu/manage-menus.js
@@ -0,0 +1,573 @@
+/*-
+ * #%L
+ * UI Module
+ * %%
+ * Copyright (C) 2023 - 2026 CondationCMS
+ * %%
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * #L%
+ */
+import { openModal } from '@cms/modules/modal.js';
+import { openFileBrowser } from '@cms/modules/filebrowser/filebrowser.js';
+import { openPagePicker } from '@cms/modules/page-picker.js';
+import { showToast } from '@cms/modules/toast.js';
+import { createMenu, deleteMenu, getMenu, listMenus, updateMenu } from '@cms/modules/rpc/rpc-menu.js';
+// @ts-ignore SortableJS is loaded as an ESM dependency from the same CDN used by the manager.
+import Sortable from 'https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/+esm';
+const clone = (value) => JSON.parse(JSON.stringify(value));
+const escapeHtml = (value) => value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+const uuid = () => {
+ if (typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID();
+ }
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+};
+const createItem = (type = 'link') => ({
+ id: uuid(),
+ type,
+ label: type === 'heading' ? 'New heading' : type === 'divider' ? 'Divider' : 'New menu item',
+ url: type === 'link' ? '/' : '',
+ target: '_self',
+ enabled: true,
+ children: []
+});
+const countItems = (items) => items.reduce((total, item) => total + 1 + countItems(item.children), 0);
+const typeMeta = {
+ link: { icon: '↗', name: 'Link' },
+ heading: { icon: 'T', name: 'Heading' },
+ divider: { icon: '―', name: 'Divider' }
+};
+class MenuManager {
+ constructor(root, modalElement) {
+ this.menu = null;
+ this.originalMenu = null;
+ this.isNew = false;
+ this.sortableElements = new WeakSet();
+ this.root = root;
+ this.modalElement = modalElement;
+ }
+ async showOverview() {
+ this.setTitle('Manage menus');
+ this.root.innerHTML = `
+ `;
+ this.root.querySelector('[data-menu-create]')?.addEventListener('click', () => {
+ const form = this.root.querySelector('[data-menu-create-form]');
+ form.hidden = false;
+ this.root.querySelector('#cms-menu-new-id').focus();
+ });
+ this.root.querySelector('[data-menu-create-cancel]')?.addEventListener('click', () => {
+ this.root.querySelector('[data-menu-create-form]').hidden = true;
+ });
+ this.root.querySelector('[data-menu-create-confirm]')?.addEventListener('click', () => this.openNewMenu());
+ await this.loadOverview();
+ }
+ async loadOverview() {
+ const list = this.root.querySelector('[data-menu-list]');
+ try {
+ const menus = await listMenus();
+ if (menus.length === 0) {
+ list.innerHTML = `
+ `;
+ return;
+ }
+ list.innerHTML = menus.map(menu => `
+ `).join('');
+ list.querySelectorAll('[data-menu-edit]').forEach(button => {
+ button.addEventListener('click', () => {
+ const id = button.closest('[data-menu-id]').dataset.menuId || '';
+ this.openExistingMenu(id);
+ });
+ });
+ list.querySelectorAll('[data-menu-delete]').forEach(button => {
+ button.addEventListener('click', () => {
+ const card = button.closest('[data-menu-id]');
+ this.removeMenu(card.dataset.menuId || '', card);
+ });
+ });
+ }
+ catch (error) {
+ list.innerHTML = 'Could not load menus.
';
+ this.toastError('Could not load menus', error);
+ }
+ }
+ openNewMenu() {
+ const idInput = this.root.querySelector('#cms-menu-new-id');
+ const nameInput = this.root.querySelector('#cms-menu-new-name');
+ const id = idInput.value.trim();
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(id)) {
+ idInput.classList.add('is-invalid');
+ idInput.focus();
+ return;
+ }
+ this.isNew = true;
+ this.menu = { id, name: nameInput.value.trim() || id, items: [] };
+ this.originalMenu = clone(this.menu);
+ this.showEditor();
+ }
+ async openExistingMenu(id) {
+ try {
+ this.menu = await getMenu(id);
+ this.originalMenu = clone(this.menu);
+ this.isNew = false;
+ this.showEditor();
+ }
+ catch (error) {
+ this.toastError('Could not load menu', error);
+ }
+ }
+ async removeMenu(id, card) {
+ if (!window.confirm(`Delete menu “${id}”?`)) {
+ return;
+ }
+ try {
+ if (await deleteMenu(id)) {
+ card.remove();
+ showToast({
+ title: 'Menu deleted',
+ message: `${id}.yaml was deleted.`,
+ type: 'success'
+ });
+ if (!this.root.querySelector('[data-menu-id]')) {
+ await this.loadOverview();
+ }
+ }
+ }
+ catch (error) {
+ this.toastError('Could not delete menu', error);
+ }
+ }
+ showEditor() {
+ if (!this.menu)
+ return;
+ this.setTitle(`Edit menu · ${this.menu.name}`);
+ this.root.innerHTML = `
+ `;
+ this.root.querySelector('[data-menu-back]')?.addEventListener('click', () => this.showOverview());
+ this.root.querySelector('[data-menu-reset]')?.addEventListener('click', () => {
+ if (this.originalMenu) {
+ this.menu = clone(this.originalMenu);
+ this.showEditor();
+ }
+ });
+ this.root.querySelector('[data-menu-save]')?.addEventListener('click', () => this.save());
+ this.root.querySelector('[data-menu-json]')?.addEventListener('click', () => this.toggleJson());
+ this.root.querySelector('[data-menu-add-main]')?.addEventListener('click', () => {
+ this.menu?.items.push(createItem());
+ this.renderMenu();
+ });
+ this.root.querySelectorAll('[data-menu-add-type]').forEach(button => {
+ button.addEventListener('click', () => {
+ this.menu?.items.push(createItem(button.dataset.menuAddType));
+ this.renderMenu();
+ });
+ });
+ this.renderMenu();
+ }
+ toolButton(type, icon, name, detail) {
+ return `
+ `;
+ }
+ renderMenu() {
+ if (!this.menu)
+ return;
+ const root = this.root.querySelector('[data-menu-root]');
+ root.replaceChildren(...this.menu.items.map(item => this.renderItem(item)));
+ this.root.querySelector('[data-menu-count]').textContent =
+ `${countItems(this.menu.items)} items`;
+ this.setupSortable(root);
+ }
+ renderItem(item) {
+ const element = document.createElement('article');
+ element.className = `cms-menu-item${item.enabled ? '' : ' is-disabled'}`;
+ element.dataset.id = item.id;
+ element.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+ element.querySelector('.cms-menu-item__copy strong').textContent = item.label;
+ const detail = item.type === 'link'
+ ? item.url || 'No URL'
+ : item.type === 'heading' ? `${item.children.length} child items` : 'Visual separator';
+ element.querySelector('.cms-menu-item__copy small').textContent =
+ `${detail}${item.enabled ? '' : ' · Disabled'}`;
+ const form = element.querySelector('form');
+ form.elements.namedItem('label').value = item.label;
+ form.elements.namedItem('url').value = item.url;
+ form.elements.namedItem('type').value = item.type;
+ form.elements.namedItem('target').value = item.target;
+ form.elements.namedItem('enabled').checked = item.enabled;
+ this.updateFormFields(form, item.type);
+ element.querySelector('[data-select-page]')?.addEventListener('click', () => {
+ openPagePicker({
+ title: 'Select internal page',
+ onSelect: page => {
+ const urlInput = form.elements.namedItem('url');
+ const labelInput = form.elements.namedItem('label');
+ urlInput.value = page.url;
+ if (page.title && (!labelInput.value.trim() || labelInput.value === 'New menu item')) {
+ labelInput.value = page.title;
+ }
+ }
+ });
+ });
+ element.querySelector('[data-browse-page]')?.addEventListener('click', () => {
+ openFileBrowser({
+ title: 'Select internal page',
+ type: 'content',
+ filter: (file) => file.directory || file.content,
+ onSelect: (file) => {
+ if (!file.url)
+ return;
+ const urlInput = form.elements.namedItem('url');
+ const labelInput = form.elements.namedItem('label');
+ urlInput.value = file.url;
+ if (file.title && (!labelInput.value.trim() || labelInput.value === 'New menu item')) {
+ labelInput.value = file.title;
+ }
+ }
+ });
+ });
+ form.elements.namedItem('type').addEventListener('change', event => {
+ this.updateFormFields(form, event.target.value);
+ });
+ element.querySelector('[data-edit-item]')?.addEventListener('click', () => {
+ form.hidden = !form.hidden;
+ if (!form.hidden)
+ form.elements.namedItem('label').focus();
+ });
+ form.addEventListener('submit', event => {
+ event.preventDefault();
+ item.label = form.elements.namedItem('label').value.trim() || 'Unnamed item';
+ item.type = form.elements.namedItem('type').value;
+ item.url = item.type === 'link' ? form.elements.namedItem('url').value.trim() : '';
+ item.target = item.type === 'link'
+ ? form.elements.namedItem('target').value
+ : '_self';
+ item.enabled = form.elements.namedItem('enabled').checked;
+ this.renderMenu();
+ });
+ element.querySelector('[data-delete-item]')?.addEventListener('click', () => {
+ if (this.menu)
+ this.removeItem(this.menu.items, item.id);
+ this.renderMenu();
+ });
+ element.querySelector('[data-add-child]')?.addEventListener('click', () => {
+ item.children.push(createItem());
+ this.renderMenu();
+ });
+ const childrenWrap = element.querySelector('.cms-menu-children');
+ const childrenList = element.querySelector('[data-children-list]');
+ if (item.children.length) {
+ childrenWrap.hidden = false;
+ childrenList.replaceChildren(...item.children.map(child => this.renderItem(child)));
+ }
+ queueMicrotask(() => this.setupSortable(childrenList));
+ return element;
+ }
+ updateFormFields(form, type) {
+ form.querySelector('[data-url-field]').hidden = type !== 'link';
+ form.querySelector('[data-target-field]').hidden = type !== 'link';
+ }
+ setupSortable(listElement) {
+ if (this.sortableElements.has(listElement)) {
+ return;
+ }
+ this.sortableElements.add(listElement);
+ Sortable.create(listElement, {
+ group: 'cms-menu-builder',
+ animation: 180,
+ fallbackOnBody: true,
+ swapThreshold: 0.65,
+ handle: '.cms-menu-drag',
+ ghostClass: 'cms-menu-sortable-ghost',
+ chosenClass: 'cms-menu-sortable-chosen',
+ onStart: () => {
+ this.root.classList.add('is-dragging-menu-item');
+ this.root.querySelectorAll('.cms-menu-children').forEach(wrap => wrap.hidden = false);
+ },
+ onMove: (event) => !event.dragged.contains(event.to),
+ onAdd: (event) => {
+ if (!this.menu)
+ return;
+ const movedItem = this.removeItem(this.menu.items, event.item.dataset.id);
+ if (!movedItem)
+ return;
+ const target = this.getListForElement(event.to);
+ target.splice(event.newIndex, 0, movedItem);
+ this.syncOrderFromDom(event.to);
+ this.renderMenu();
+ },
+ onUpdate: (event) => {
+ this.syncOrderFromDom(event.to);
+ this.renderMenu();
+ },
+ onEnd: () => {
+ this.root.classList.remove('is-dragging-menu-item');
+ this.root.querySelectorAll('.cms-menu-children').forEach(wrap => {
+ const list = wrap.querySelector('[data-children-list]');
+ wrap.hidden = list.children.length === 0;
+ });
+ }
+ });
+ }
+ getListForElement(listElement) {
+ if (listElement.hasAttribute('data-menu-root'))
+ return this.menu?.items || [];
+ const parent = listElement.closest('.cms-menu-item');
+ return this.findItem(this.menu?.items || [], parent?.dataset.id || '')?.children || [];
+ }
+ syncOrderFromDom(listElement) {
+ const target = this.getListForElement(listElement);
+ const ids = Array.from(listElement.children)
+ .filter(element => element.classList.contains('cms-menu-item'))
+ .map(element => element.dataset.id);
+ target.sort((left, right) => ids.indexOf(left.id) - ids.indexOf(right.id));
+ }
+ findItem(items, id) {
+ for (const item of items) {
+ if (item.id === id)
+ return item;
+ const nested = this.findItem(item.children, id);
+ if (nested)
+ return nested;
+ }
+ return null;
+ }
+ removeItem(items, id) {
+ const index = items.findIndex(item => item.id === id);
+ if (index >= 0)
+ return items.splice(index, 1)[0];
+ for (const item of items) {
+ const removed = this.removeItem(item.children, id);
+ if (removed)
+ return removed;
+ }
+ return null;
+ }
+ toggleJson() {
+ if (!this.menu)
+ return;
+ this.readName();
+ const output = this.root.querySelector('[data-menu-json-output]');
+ output.textContent = JSON.stringify(this.menu, null, 2);
+ output.hidden = !output.hidden;
+ }
+ readName() {
+ if (!this.menu)
+ return;
+ const input = this.root.querySelector('[data-menu-name]');
+ this.menu.name = input.value.trim() || this.menu.id;
+ }
+ async save() {
+ if (!this.menu)
+ return;
+ this.readName();
+ const saveButton = this.root.querySelector('[data-menu-save]');
+ saveButton.disabled = true;
+ try {
+ this.menu = this.isNew ? await createMenu(this.menu) : await updateMenu(this.menu);
+ this.originalMenu = clone(this.menu);
+ this.isNew = false;
+ this.setTitle(`Edit menu · ${this.menu.name}`);
+ showToast({
+ title: 'Menu saved',
+ message: `${this.menu.id}.yaml was updated.`,
+ type: 'success'
+ });
+ }
+ catch (error) {
+ this.toastError('Could not save menu', error);
+ }
+ finally {
+ saveButton.disabled = false;
+ }
+ }
+ setTitle(title) {
+ const titleElement = this.modalElement.querySelector('.modal-title');
+ if (titleElement)
+ titleElement.textContent = title;
+ }
+ toastError(title, error) {
+ showToast({
+ title,
+ message: error instanceof Error ? error.message : 'Unknown error',
+ type: 'error'
+ });
+ }
+}
+export const runAction = async () => {
+ openModal({
+ title: 'Manage menus',
+ body: '',
+ fullscreen: true,
+ showFooter: false,
+ onShow: (modalElement) => {
+ const root = modalElement.querySelector('.cms-menu-manager-root');
+ new MenuManager(root, modalElement).showOverview();
+ }
+ });
+};
diff --git a/modules/ui-module/src/main/resources/manager/actions/page/search-pages.js b/modules/ui-module/src/main/resources/manager/actions/page/search-pages.js
index 2f805b2b7..ef5a686d2 100644
--- a/modules/ui-module/src/main/resources/manager/actions/page/search-pages.js
+++ b/modules/ui-module/src/main/resources/manager/actions/page/search-pages.js
@@ -18,91 +18,13 @@
* along with this program. If not, see .
* #L%
*/
-import { openModal } from '@cms/modules/modal.js';
import { i18n } from '@cms/modules/localization.js';
-import { searchPages } from '@cms/modules/rpc/rpc-page';
+import { openPagePicker } from '@cms/modules/page-picker.js';
import { loadPreview } from '@cms/modules/preview.utils';
-const MIN_QUERY_LENGTH = 3;
-const renderResultsHtml = (results, query) => {
- if (query.trim().length < MIN_QUERY_LENGTH) {
- return `${i18n.t('page.search.minLength', 'Enter at least 3 characters and press Enter to search.')}
`;
- }
- if (results.length === 0) {
- return `${i18n.t('page.search.noResults', 'No pages found.')}
`;
- }
- return `
-
-
-
- ${i18n.t('page.search.columnTitle', 'Title')}
- ${i18n.t('page.search.columnUri', 'URI')}
-
-
-
-
- ${results.map(result => `
-
- ${result.title}
- ${result.uri}
-
-
- ${i18n.t('page.search.loadLink', 'Load')}
-
-
-
- `).join('')}
-
-
- `;
-};
-const state = {
- modal: null
-};
-const bindResultLinks = (resultsElement) => {
- resultsElement.querySelectorAll('a[data-cms-page-uri]').forEach((link) => {
- link.addEventListener('click', (e) => {
- e.preventDefault();
- state.modal.hide();
- loadPreview(link.dataset.cmsPageUri || '');
- });
- });
-};
-const runSearch = async (query, resultsElement) => {
- if (query.trim().length < MIN_QUERY_LENGTH) {
- resultsElement.innerHTML = renderResultsHtml([], query);
- return;
- }
- try {
- const response = await searchPages({ query: query.trim() });
- resultsElement.innerHTML = renderResultsHtml(response.result, query);
- bindResultLinks(resultsElement);
- }
- catch (e) {
- resultsElement.innerHTML = `${i18n.t('page.search.loadError', 'Could not search pages.')}
`;
- }
-};
export const runAction = async (options = {}) => {
- state.modal = openModal({
+ openPagePicker({
title: i18n.t('page.search.title', 'Search pages'),
- body: `
-
-
- `,
- fullscreen: false,
- onCancel: () => { },
- onOk: () => { },
- onShow: (modalElement) => {
- const inputElement = modalElement.querySelector('#cms-search-pages-input');
- const resultsElement = modalElement.querySelector('#cms-search-pages-results');
- resultsElement.innerHTML = renderResultsHtml([], '');
- inputElement.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- runSearch(inputElement.value, resultsElement);
- }
- });
- inputElement.focus();
- }
+ selectText: i18n.t('page.search.loadLink', 'Load'),
+ onSelect: page => loadPreview(page.uri)
});
};
diff --git a/modules/ui-module/src/main/resources/manager/css/manager.css b/modules/ui-module/src/main/resources/manager/css/manager.css
index ec8f23374..c89615f16 100644
--- a/modules/ui-module/src/main/resources/manager/css/manager.css
+++ b/modules/ui-module/src/main/resources/manager/css/manager.css
@@ -146,6 +146,589 @@ i[data-cms-section-handle] {
}
}
+/* =========================================================
+ MENU MANAGER
+========================================================= */
+
+.cms-menu-manager-root {
+ --cms-menu-panel: rgba(26, 34, 43, 0.86);
+ --cms-menu-surface: rgba(37, 48, 59, 0.82);
+ --cms-menu-surface-hover: rgba(42, 56, 69, 0.95);
+ --cms-menu-line: rgba(178, 191, 205, 0.18);
+ --cms-menu-muted: #8b98a8;
+ --cms-menu-text: #f4f7fb;
+ min-height: 100%;
+ color: var(--cms-menu-text);
+}
+
+.cms-menu-manager-root button,
+.cms-menu-manager-root input,
+.cms-menu-manager-root select {
+ font: inherit;
+}
+
+.cms-menu-eyebrow {
+ margin: 0 0 0.35rem;
+ color: var(--bs-primary);
+ font-size: 0.7rem;
+ font-weight: 700;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.cms-menu-overview {
+ width: min(1080px, 100%);
+ margin: 0 auto;
+ padding: 1rem;
+}
+
+.cms-menu-overview__heading {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.cms-menu-overview__heading h2,
+.cms-menu-overview__heading p {
+ margin: 0;
+}
+
+.cms-menu-overview__heading > div > p:last-child {
+ margin-top: 0.4rem;
+ color: var(--cms-menu-muted);
+}
+
+.cms-menu-create {
+ display: grid;
+ grid-template-columns: minmax(12rem, 1fr) minmax(12rem, 1fr) auto;
+ gap: 1rem;
+ margin-bottom: 1rem;
+ padding: 1rem;
+ border-color: var(--cms-menu-line);
+ background: var(--cms-menu-panel);
+}
+
+.cms-menu-list {
+ display: grid;
+ gap: 0.65rem;
+}
+
+.cms-menu-card {
+ display: grid;
+ grid-template-columns: 2.75rem minmax(0, 1fr) auto auto;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.9rem 1rem;
+ border-color: var(--cms-menu-line);
+ background: var(--cms-menu-panel);
+ transition: border-color 150ms ease, background-color 150ms ease;
+}
+
+.cms-menu-card:hover {
+ border-color: rgba(var(--bs-primary-rgb), 0.65);
+ background: var(--cms-menu-surface-hover);
+}
+
+.cms-menu-card__icon {
+ width: 2.75rem;
+ height: 2.75rem;
+ display: grid;
+ place-items: center;
+ color: var(--bs-primary);
+ background: rgba(var(--bs-primary-rgb), 0.1);
+ font-size: 1.25rem;
+}
+
+.cms-menu-card__copy {
+ display: grid;
+ min-width: 0;
+ gap: 0.15rem;
+}
+
+.cms-menu-card__copy code {
+ color: var(--cms-menu-muted);
+ font-size: 0.72rem;
+}
+
+.cms-menu-card__actions {
+ display: flex;
+ gap: 0.4rem;
+}
+
+.cms-menu-empty {
+ min-height: 14rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.45rem;
+ color: var(--cms-menu-muted);
+ border: 1px dashed var(--cms-menu-line);
+ background: var(--cms-menu-panel);
+}
+
+.cms-menu-empty > i {
+ color: var(--bs-primary);
+ font-size: 2rem;
+}
+
+.cms-menu-empty strong {
+ color: var(--cms-menu-text);
+}
+
+.cms-menu-builder {
+ min-height: 100%;
+}
+
+.cms-menu-builder__bar {
+ position: sticky;
+ top: -1rem;
+ z-index: 10;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 1rem;
+ margin: -1rem -1rem 1.25rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--cms-menu-line);
+ background: rgba(20, 27, 35, 0.97);
+}
+
+.cms-menu-builder__identity {
+ display: flex;
+ align-items: end;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.cms-menu-builder__identity label {
+ display: grid;
+ grid-template-columns: auto minmax(12rem, 24rem);
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0;
+}
+
+.cms-menu-builder__identity label > span {
+ color: var(--cms-menu-muted);
+ font-size: 0.72rem;
+}
+
+.cms-menu-builder__identity code {
+ padding-bottom: 0.35rem;
+ color: var(--cms-menu-muted);
+ font-size: 0.7rem;
+}
+
+.cms-menu-builder__actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.cms-menu-builder__workspace {
+ width: min(1240px, 100%);
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: 260px minmax(0, 1fr);
+ align-items: start;
+ gap: 1rem;
+}
+
+.cms-menu-toolbox,
+.cms-menu-editor {
+ border-color: var(--cms-menu-line);
+ background: var(--cms-menu-panel);
+}
+
+.cms-menu-toolbox {
+ position: sticky;
+ top: 4.5rem;
+ padding: 1.25rem;
+}
+
+.cms-menu-toolbox h3,
+.cms-menu-editor h3 {
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+}
+
+.cms-menu-tools {
+ display: grid;
+ gap: 0.5rem;
+ margin-top: 1.25rem;
+}
+
+.cms-menu-tool {
+ width: 100%;
+ display: grid;
+ grid-template-columns: 2.1rem 1fr auto;
+ align-items: center;
+ gap: 0.65rem;
+ padding: 0.7rem;
+ color: var(--cms-menu-text);
+ text-align: left;
+ border: 1px solid var(--cms-menu-line);
+ background: var(--cms-menu-surface);
+ transition: 150ms ease;
+}
+
+.cms-menu-tool:hover {
+ border-color: rgba(var(--bs-primary-rgb), 0.65);
+ background: var(--cms-menu-surface-hover);
+ box-shadow: inset 3px 0 0 var(--bs-primary);
+}
+
+.cms-menu-tool strong,
+.cms-menu-tool small {
+ display: block;
+}
+
+.cms-menu-tool strong {
+ font-size: 0.78rem;
+}
+
+.cms-menu-tool small {
+ margin-top: 0.12rem;
+ color: var(--cms-menu-muted);
+ font-size: 0.62rem;
+}
+
+.cms-menu-tool__icon {
+ width: 2.1rem;
+ height: 2.1rem;
+ display: grid;
+ place-items: center;
+ color: #79d7ff;
+ font-weight: 700;
+ background: rgba(var(--bs-primary-rgb), 0.1);
+}
+
+.cms-menu-tool__plus {
+ color: var(--cms-menu-muted);
+ font-size: 1.1rem;
+}
+
+.cms-menu-tip {
+ display: flex;
+ gap: 0.6rem;
+ margin-top: 1.2rem;
+ padding: 0.8rem;
+ color: var(--cms-menu-muted);
+ background: rgba(var(--bs-primary-rgb), 0.07);
+ font-size: 0.68rem;
+ line-height: 1.45;
+}
+
+.cms-menu-tip i {
+ color: var(--bs-primary);
+}
+
+.cms-menu-tip strong {
+ display: block;
+ color: var(--cms-menu-text);
+}
+
+.cms-menu-editor {
+ padding: 1.25rem;
+}
+
+.cms-menu-editor__heading {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.cms-menu-columns {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 180px 130px;
+ gap: 1rem;
+ margin-top: 1.4rem;
+ padding: 0 0.85rem 0.5rem 3.9rem;
+ color: var(--cms-menu-muted);
+ font-size: 0.58rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.cms-menu-tree,
+.cms-menu-sortable {
+ display: grid;
+ gap: 0.45rem;
+ min-height: 0.35rem;
+}
+
+.cms-menu-item__main {
+ display: grid;
+ grid-template-columns: 1.4rem 2.15rem minmax(0, 1fr) 150px 130px;
+ align-items: center;
+ gap: 0.65rem;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid var(--cms-menu-line);
+ background: var(--cms-menu-surface);
+ transition: 150ms ease;
+}
+
+.cms-menu-item__main:hover {
+ border-color: rgba(var(--bs-primary-rgb), 0.5);
+ background: var(--cms-menu-surface-hover);
+}
+
+.cms-menu-item.is-disabled > .cms-menu-item__main {
+ opacity: 0.56;
+}
+
+.cms-menu-drag {
+ padding: 0;
+ color: var(--cms-menu-muted);
+ border: 0;
+ background: transparent;
+ cursor: grab;
+}
+
+.cms-menu-drag:active {
+ cursor: grabbing;
+}
+
+.cms-menu-type {
+ width: 2.15rem;
+ height: 2.15rem;
+ display: grid;
+ place-items: center;
+ color: #79d7ff;
+ background: rgba(var(--bs-primary-rgb), 0.1);
+ font-weight: 700;
+}
+
+.cms-menu-item__copy {
+ display: grid;
+ min-width: 0;
+}
+
+.cms-menu-item__copy strong {
+ overflow: hidden;
+ font-size: 0.78rem;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.cms-menu-item__copy small {
+ overflow: hidden;
+ color: var(--cms-menu-muted);
+ font-size: 0.64rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.cms-menu-kind {
+ color: var(--cms-menu-muted);
+ font-size: 0.68rem;
+}
+
+.cms-menu-item__actions {
+ display: flex;
+ justify-content: end;
+ gap: 0.3rem;
+}
+
+.cms-menu-item__form {
+ display: grid;
+ grid-template-columns: 1.2fr 1.5fr 0.8fr 0.9fr auto auto;
+ align-items: end;
+ gap: 0.6rem;
+ margin: 0.4rem 0 0.65rem 2.05rem;
+ padding: 0.75rem;
+ border-left: 2px solid var(--bs-primary);
+ background: rgba(11, 17, 23, 0.42);
+}
+
+.cms-menu-item__form[hidden] {
+ display: none;
+}
+
+.cms-menu-item__form label {
+ display: grid;
+ gap: 0.25rem;
+ margin: 0;
+}
+
+.cms-menu-item__form label > span {
+ color: var(--cms-menu-muted);
+ font-size: 0.6rem;
+ font-weight: 600;
+}
+
+.cms-menu-item__form label > .input-group {
+ color: inherit;
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+.cms-menu-enabled {
+ display: flex !important;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ align-self: center;
+ gap: 0.35rem !important;
+ white-space: nowrap;
+}
+
+.cms-menu-children {
+ position: relative;
+ margin: 0.45rem 0 0.45rem 1.55rem;
+ padding-left: 1.4rem;
+}
+
+.cms-menu-children[hidden] {
+ display: none;
+}
+
+.cms-menu-branch {
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 1px;
+ background: rgba(var(--bs-primary-rgb), 0.35);
+}
+
+.is-dragging-menu-item .cms-menu-sortable:empty {
+ min-height: 2.8rem;
+ border: 1px dashed rgba(var(--bs-primary-rgb), 0.45);
+ background: rgba(var(--bs-primary-rgb), 0.04);
+}
+
+.cms-menu-add-main {
+ width: 100%;
+ margin-top: 0.75rem;
+ padding: 0.7rem;
+ color: var(--cms-menu-muted);
+ border: 1px dashed var(--cms-menu-line);
+ background: transparent;
+ font-size: 0.72rem;
+ transition: 150ms ease;
+}
+
+.cms-menu-add-main:hover {
+ color: var(--cms-menu-text);
+ border-color: rgba(var(--bs-primary-rgb), 0.6);
+ background: rgba(var(--bs-primary-rgb), 0.06);
+}
+
+.cms-menu-add-main span {
+ margin-right: 0.4rem;
+ font-size: 1rem;
+}
+
+.cms-menu-sortable-ghost {
+ opacity: 0.28;
+}
+
+.cms-menu-sortable-chosen > .cms-menu-item__main {
+ border-color: var(--bs-primary);
+ background: var(--cms-menu-surface-hover);
+ box-shadow: inset 3px 0 0 var(--bs-primary);
+}
+
+.cms-menu-json {
+ max-height: 22rem;
+ margin: 1rem 0 0;
+ padding: 1rem;
+ overflow: auto;
+ color: #dbe3ee;
+ border: 1px solid var(--cms-menu-line);
+ background: #111820;
+ font: 0.72rem/1.65 var(--bs-font-monospace);
+}
+
+@media (max-width: 980px) {
+ .cms-menu-builder__bar {
+ grid-template-columns: auto 1fr;
+ }
+
+ .cms-menu-builder__actions {
+ grid-column: 1 / -1;
+ justify-content: end;
+ }
+
+ .cms-menu-builder__workspace {
+ grid-template-columns: 1fr;
+ }
+
+ .cms-menu-toolbox {
+ position: static;
+ }
+
+ .cms-menu-tools {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ .cms-menu-tip {
+ display: none;
+ }
+
+ .cms-menu-item__form {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 680px) {
+ .cms-menu-create,
+ .cms-menu-card {
+ grid-template-columns: 1fr;
+ }
+
+ .cms-menu-card__icon,
+ .cms-menu-card > .badge {
+ display: none;
+ }
+
+ .cms-menu-builder__identity code,
+ .cms-menu-columns,
+ .cms-menu-builder__actions [data-menu-reset],
+ .cms-menu-builder__actions [data-menu-json] {
+ display: none;
+ }
+
+ .cms-menu-builder__identity label {
+ grid-template-columns: 1fr;
+ }
+
+ .cms-menu-tools {
+ grid-template-columns: 1fr;
+ }
+
+ .cms-menu-editor {
+ padding: 0.7rem;
+ }
+
+ .cms-menu-item__main {
+ grid-template-columns: 1.2rem 2rem minmax(0, 1fr) auto;
+ gap: 0.45rem;
+ }
+
+ .cms-menu-kind {
+ display: none;
+ }
+
+ .cms-menu-item__actions {
+ grid-column: 3 / 5;
+ }
+
+ .cms-menu-item__form {
+ margin-left: 1rem;
+ grid-template-columns: 1fr;
+ }
+
+ .cms-menu-children {
+ margin-left: 1rem;
+ padding-left: 0.8rem;
+ }
+}
+
/* =========================================================
MEDIA BROWSER GRID
========================================================= */
diff --git a/modules/ui-module/src/main/resources/manager/index.html b/modules/ui-module/src/main/resources/manager/index.html
index 40c367f85..0d8f77236 100644
--- a/modules/ui-module/src/main/resources/manager/index.html
+++ b/modules/ui-module/src/main/resources/manager/index.html
@@ -159,13 +159,13 @@
{% /if %}
-
-
diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.js b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.js
index cfa6c377e..63ec8e3fe 100644
--- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.js
+++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.js
@@ -55,7 +55,9 @@ const openFileBrowser = async (optionsParam) => {
if (selectedRow && state.options.onSelect) {
const uri = selectedRow.getAttribute("data-cms-file-uri");
const name = selectedRow.getAttribute("data-cms-file-name");
- state.options.onSelect({ uri, name });
+ const title = selectedRow.getAttribute("data-cms-file-title");
+ const url = selectedRow.getAttribute("data-cms-file-url");
+ state.options.onSelect({ uri, name, title, url });
}
},
onShow: async () => {
@@ -108,8 +110,10 @@ const makeFilesSelectable = () => {
row.addEventListener("dblclick", () => {
const uri = row.getAttribute("data-cms-file-uri");
const name = row.getAttribute("data-cms-file-name");
+ const title = row.getAttribute("data-cms-file-title");
+ const url = row.getAttribute("data-cms-file-url");
if (state.options.onSelect) {
- state.options.onSelect({ uri, name });
+ state.options.onSelect({ uri, name, title, url });
}
state.modal.hide();
});
diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.template.js b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.template.js
index 80ac1e52f..bee046d24 100644
--- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.template.js
+++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.template.js
@@ -71,6 +71,8 @@ const template = Handlebars.compile(`
diff --git a/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.d.ts
index 8e0175fee..94c485674 100644
--- a/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.d.ts
+++ b/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.d.ts
@@ -43,6 +43,10 @@ export namespace MODULE_LOCALIZATIONS {
"page.search.columnUri": string;
"page.search.loadLink": string;
"page.search.loadError": string;
+ "page.picker.title": string;
+ "page.picker.searchLabel": string;
+ "page.picker.searching": string;
+ "page.picker.select": string;
"pagination.previous": string;
"pagination.next": string;
};
diff --git a/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.js b/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.js
index 77c895d55..34cfa0539 100644
--- a/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.js
+++ b/modules/ui-module/src/main/resources/manager/js/modules/localization-modules.js
@@ -37,12 +37,16 @@ export const MODULE_LOCALIZATIONS = {
"page.unpublished.editLink": "Bearbeiten",
"page.search.title": "Seiten suchen",
"page.search.placeholder": "Nach Titel suchen...",
- "page.search.minLength": "Mindestens 3 Zeichen eingeben und Enter drücken, um zu suchen.",
+ "page.search.minLength": "Mindestens 3 Zeichen eingeben, um zu suchen.",
"page.search.noResults": "Keine Seiten gefunden.",
"page.search.columnTitle": "Titel",
"page.search.columnUri": "URI",
"page.search.loadLink": "Laden",
"page.search.loadError": "Seiten konnten nicht durchsucht werden.",
+ "page.picker.title": "Seite auswählen",
+ "page.picker.searchLabel": "Nach Seitentitel suchen",
+ "page.picker.searching": "Seiten werden gesucht...",
+ "page.picker.select": "Auswählen",
"pagination.previous": "Zurück",
"pagination.next": "Weiter"
}
diff --git a/modules/ui-module/src/main/resources/manager/js/modules/modal.js b/modules/ui-module/src/main/resources/manager/js/modules/modal.js
index cf2316b71..f67f6b7c8 100644
--- a/modules/ui-module/src/main/resources/manager/js/modules/modal.js
+++ b/modules/ui-module/src/main/resources/manager/js/modules/modal.js
@@ -36,6 +36,7 @@ const openModal = (optionsParam) => {
if (options.size) {
size = "modal-" + options.size;
}
+ const footerClass = options.showFooter === false ? "d-none" : "";
const modalHtml = `
@@ -47,7 +48,7 @@ const openModal = (optionsParam) => {
${options.body || ''}
-