From 76c5ae8aebf53682b887a4e6bd5df7eaf60b2b8e Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Fri, 31 Jul 2026 11:48:44 +0200 Subject: [PATCH 1/7] menu builder --- .../java/com/condation/cms/api/menu/Menu.java | 34 ++ .../com/condation/cms/api/menu/MenuItem.java | 41 ++ .../condation/cms/api/menu/MenuService.java | 42 ++ .../cms/core/menu/FileMenuService.java | 249 ++++++++ .../cms/core/menu/FileMenuServiceTest.java | 80 +++ .../cms/server/configs/SiteModule.java | 8 + .../ui/extensionpoints/SiteMenuExtension.java | 10 + .../remotemethods/RemoteMenuEndpoints.java | 127 ++++ .../manager/actions/menu/manage-menus.d.ts | 21 + .../manager/actions/menu/manage-menus.js | 531 ++++++++++++++++ .../main/resources/manager/css/manager.css | 577 ++++++++++++++++++ .../resources/manager/js/manager-inject.js | 1 - .../js/modules/filebrowser/filebrowser.d.ts | 2 +- .../manager/js/modules/form/utils.d.ts | 4 +- .../manager/js/modules/localization.d.ts | 2 +- .../js/modules/media/mediabrowser.d.ts | 6 +- .../resources/manager/js/modules/modal.js | 3 +- .../manager/js/modules/preview.history.d.ts | 2 +- .../manager/js/modules/preview.utils.d.ts | 2 +- .../manager/js/modules/rpc/rpc-menu.d.ts | 40 ++ .../manager/js/modules/rpc/rpc-menu.js | 56 ++ .../resources/manager/js/modules/state.js | 1 - .../manager/js/modules/ui-state.d.ts | 4 +- .../resources/manager/public/manager-login.js | 1 - .../main/ts/src/actions/menu/manage-menus.ts | 564 +++++++++++++++++ .../src/main/ts/src/js/modules/modal.js | 3 +- .../main/ts/src/js/modules/rpc/rpc-menu.ts | 80 +++ .../RemoteMenuEndpointsTest.java | 72 +++ test-server/hosts/demo/config/menus/main.yaml | 18 + 29 files changed, 2565 insertions(+), 16 deletions(-) create mode 100644 cms-api/src/main/java/com/condation/cms/api/menu/Menu.java create mode 100644 cms-api/src/main/java/com/condation/cms/api/menu/MenuItem.java create mode 100644 cms-api/src/main/java/com/condation/cms/api/menu/MenuService.java create mode 100644 cms-core/src/main/java/com/condation/cms/core/menu/FileMenuService.java create mode 100644 cms-core/src/test/java/com/condation/cms/core/menu/FileMenuServiceTest.java create mode 100644 modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteMenuEndpoints.java create mode 100644 modules/ui-module/src/main/resources/manager/actions/menu/manage-menus.d.ts create mode 100644 modules/ui-module/src/main/resources/manager/actions/menu/manage-menus.js create mode 100644 modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-menu.d.ts create mode 100644 modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-menu.js create mode 100644 modules/ui-module/src/main/ts/src/actions/menu/manage-menus.ts create mode 100644 modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-menu.ts create mode 100644 modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteMenuEndpointsTest.java create mode 100644 test-server/hosts/demo/config/menus/main.yaml diff --git a/cms-api/src/main/java/com/condation/cms/api/menu/Menu.java b/cms-api/src/main/java/com/condation/cms/api/menu/Menu.java new file mode 100644 index 000000000..1353e40cd --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/menu/Menu.java @@ -0,0 +1,34 @@ +package com.condation.cms.api.menu; + +/*- + * #%L + * CMS Api + * %% + * 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 java.util.List; + +/** + * A named navigation menu. The id is also used as the YAML file name. + */ +public record Menu(String id, String name, List items) { + + public Menu { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/cms-api/src/main/java/com/condation/cms/api/menu/MenuItem.java b/cms-api/src/main/java/com/condation/cms/api/menu/MenuItem.java new file mode 100644 index 000000000..91266dd72 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/menu/MenuItem.java @@ -0,0 +1,41 @@ +package com.condation.cms.api.menu; + +/*- + * #%L + * CMS Api + * %% + * 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 java.util.List; + +/** + * A single, potentially nested entry in a navigation menu. + */ +public record MenuItem( + String id, + String type, + String label, + String url, + String target, + boolean enabled, + List children) { + + public MenuItem { + children = children == null ? List.of() : List.copyOf(children); + } +} diff --git a/cms-api/src/main/java/com/condation/cms/api/menu/MenuService.java b/cms-api/src/main/java/com/condation/cms/api/menu/MenuService.java new file mode 100644 index 000000000..c34d16c60 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/menu/MenuService.java @@ -0,0 +1,42 @@ +package com.condation.cms.api.menu; + +/*- + * #%L + * CMS Api + * %% + * 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 java.io.IOException; +import java.util.List; +import java.util.Optional; + +/** + * Storage abstraction for site navigation menus. + */ +public interface MenuService { + + List list() throws IOException; + + Optional get(String id) throws IOException; + + Menu create(Menu menu) throws IOException; + + Menu update(Menu menu) throws IOException; + + boolean delete(String id) throws IOException; +} diff --git a/cms-core/src/main/java/com/condation/cms/core/menu/FileMenuService.java b/cms-core/src/main/java/com/condation/cms/core/menu/FileMenuService.java new file mode 100644 index 000000000..9fda1c9cf --- /dev/null +++ b/cms-core/src/main/java/com/condation/cms/core/menu/FileMenuService.java @@ -0,0 +1,249 @@ +package com.condation.cms.core.menu; + +/*- + * #%L + * CMS Core + * %% + * 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 com.condation.cms.api.menu.Menu; +import com.condation.cms.api.menu.MenuItem; +import com.condation.cms.api.menu.MenuService; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; + +/** + * YAML-backed menu storage scoped to one site directory. + */ +public class FileMenuService implements MenuService { + + private static final Pattern VALID_ID = Pattern.compile("[a-zA-Z0-9][a-zA-Z0-9_-]*"); + + private final Path menusDirectory; + + public FileMenuService(Path hostBase) { + this.menusDirectory = hostBase.resolve("config/menus").normalize(); + } + + @Override + public synchronized List list() throws IOException { + if (!Files.isDirectory(menusDirectory)) { + return List.of(); + } + + try (Stream paths = Files.list(menusDirectory)) { + List menuFiles = paths + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".yaml")) + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .toList(); + + List menus = new ArrayList<>(menuFiles.size()); + for (Path menuFile : menuFiles) { + menus.add(read(menuFile)); + } + return List.copyOf(menus); + } + } + + @Override + public synchronized Optional get(String id) throws IOException { + Path menuFile = resolve(id); + if (!Files.isRegularFile(menuFile)) { + return Optional.empty(); + } + return Optional.of(read(menuFile)); + } + + @Override + public synchronized Menu create(Menu menu) throws IOException { + Menu normalizedMenu = normalize(menu); + Path target = resolve(normalizedMenu.id()); + if (Files.exists(target)) { + throw new FileAlreadyExistsException(target.toString()); + } + write(target, normalizedMenu); + return normalizedMenu; + } + + @Override + public synchronized Menu update(Menu menu) throws IOException { + Menu normalizedMenu = normalize(menu); + Path target = resolve(normalizedMenu.id()); + if (!Files.isRegularFile(target)) { + throw new NoSuchFileException(target.toString()); + } + write(target, normalizedMenu); + return normalizedMenu; + } + + @Override + public synchronized boolean delete(String id) throws IOException { + return Files.deleteIfExists(resolve(id)); + } + + private Path resolve(String id) { + validateId(id); + Path resolved = menusDirectory.resolve(id + ".yaml").normalize(); + if (!resolved.startsWith(menusDirectory)) { + throw new IllegalArgumentException("Invalid menu id"); + } + return resolved; + } + + private Menu normalize(Menu menu) { + if (menu == null) { + throw new IllegalArgumentException("Menu is required"); + } + validateId(menu.id()); + String name = menu.name() == null || menu.name().isBlank() ? menu.id() : menu.name().trim(); + return new Menu(menu.id(), name, menu.items()); + } + + private void validateId(String id) { + if (id == null || !VALID_ID.matcher(id).matches()) { + throw new IllegalArgumentException( + "Menu id must only contain letters, numbers, hyphens and underscores"); + } + } + + private Menu read(Path menuFile) throws IOException { + String id = fileId(menuFile); + String content = Files.readString(menuFile, StandardCharsets.UTF_8); + if (content.isBlank()) { + return new Menu(id, id, List.of()); + } + + Object loaded = createYaml().load(content); + if (!(loaded instanceof Map document)) { + throw new IOException("Invalid menu document: " + menuFile.getFileName()); + } + + String documentId = stringValue(document.get("id"), id); + if (!id.equals(documentId)) { + throw new IOException("Menu id does not match file name: " + menuFile.getFileName()); + } + String name = stringValue(document.get("name"), id); + return new Menu(id, name, readItems(document.get("items"), menuFile)); + } + + private List readItems(Object value, Path menuFile) throws IOException { + if (value == null) { + return List.of(); + } + if (!(value instanceof List values)) { + throw new IOException("Invalid menu items in " + menuFile.getFileName()); + } + + List items = new ArrayList<>(values.size()); + for (Object itemValue : values) { + if (!(itemValue instanceof Map item)) { + throw new IOException("Invalid menu item in " + menuFile.getFileName()); + } + items.add(new MenuItem( + stringValue(item.get("id"), ""), + stringValue(item.get("type"), "link"), + stringValue(item.get("label"), ""), + stringValue(item.get("url"), ""), + stringValue(item.get("target"), "_self"), + booleanValue(item.get("enabled"), true), + readItems(item.get("children"), menuFile))); + } + return items; + } + + private void write(Path target, Menu menu) throws IOException { + Files.createDirectories(menusDirectory); + Path temporaryFile = Files.createTempFile(menusDirectory, "." + menu.id() + "-", ".yaml.tmp"); + try { + Files.writeString(temporaryFile, createYaml().dump(toDocument(menu)), StandardCharsets.UTF_8); + try { + Files.move(temporaryFile, target, + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(temporaryFile, target, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporaryFile); + } + } + + private Map toDocument(Menu menu) { + Map document = new LinkedHashMap<>(); + document.put("id", menu.id()); + document.put("name", menu.name()); + List> items = new ArrayList<>(); + menu.items().forEach(item -> items.add(toDocument(item))); + document.put("items", items); + return document; + } + + private Map toDocument(MenuItem item) { + Map document = new LinkedHashMap<>(); + document.put("id", item.id()); + document.put("type", item.type()); + document.put("label", item.label()); + document.put("url", item.url()); + document.put("target", item.target()); + document.put("enabled", item.enabled()); + List> children = new ArrayList<>(); + item.children().forEach(child -> children.add(toDocument(child))); + document.put("children", children); + return document; + } + + private String fileId(Path menuFile) { + String fileName = menuFile.getFileName().toString(); + return fileName.substring(0, fileName.length() - ".yaml".length()); + } + + private String stringValue(Object value, String fallback) { + return value == null ? fallback : String.valueOf(value); + } + + private boolean booleanValue(Object value, boolean fallback) { + if (value instanceof Boolean bool) { + return bool; + } + return value == null ? fallback : Boolean.parseBoolean(String.valueOf(value)); + } + + private Yaml createYaml() { + DumperOptions options = new DumperOptions(); + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + options.setPrettyFlow(true); + options.setIndent(2); + return new Yaml(options); + } +} diff --git a/cms-core/src/test/java/com/condation/cms/core/menu/FileMenuServiceTest.java b/cms-core/src/test/java/com/condation/cms/core/menu/FileMenuServiceTest.java new file mode 100644 index 000000000..258e1880b --- /dev/null +++ b/cms-core/src/test/java/com/condation/cms/core/menu/FileMenuServiceTest.java @@ -0,0 +1,80 @@ +package com.condation.cms.core.menu; + +/*- + * #%L + * CMS Core + * %% + * 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 com.condation.cms.api.menu.Menu; +import com.condation.cms.api.menu.MenuItem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FileMenuServiceTest { + + @TempDir + Path siteDirectory; + + @Test + void storesMenuAsYamlAndSupportsCrudOperations() throws Exception { + var service = new FileMenuService(siteDirectory); + var item = new MenuItem( + "home", + "link", + "Startseite", + "/", + "_self", + true, + List.of()); + var menu = new Menu("main-navigation", "Hauptnavigation", List.of(item)); + + service.create(menu); + + Path menuFile = siteDirectory.resolve("config/menus/main-navigation.yaml"); + Assertions.assertThat(menuFile).isRegularFile(); + Assertions.assertThat(Files.readString(menuFile)) + .contains("id: main-navigation") + .contains("label: Startseite") + .doesNotContain("{\"id\"") + .doesNotContain("&id"); + Assertions.assertThat(service.get("main-navigation")).contains(menu); + Assertions.assertThat(service.list()).containsExactly(menu); + + var updated = new Menu("main-navigation", "Main menu", List.of(item, item)); + service.update(updated); + Assertions.assertThat(service.get("main-navigation")).contains(updated); + + Assertions.assertThat(service.delete("main-navigation")).isTrue(); + Assertions.assertThat(service.get("main-navigation")).isEmpty(); + Assertions.assertThat(service.delete("main-navigation")).isFalse(); + } + + @Test + void rejectsIdsThatCouldEscapeTheMenuDirectory() { + var service = new FileMenuService(siteDirectory); + var menu = new Menu("../outside", "Outside", List.of()); + + Assertions.assertThatThrownBy(() -> service.create(menu)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java b/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java index f43d3752e..8eb52afe1 100644 --- a/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java +++ b/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java @@ -45,6 +45,7 @@ import com.condation.cms.api.eventbus.EventBus; import com.condation.cms.api.eventbus.events.ConfigurationReloadEvent; import com.condation.cms.api.mail.MailService; +import com.condation.cms.api.menu.MenuService; import com.condation.cms.api.mapper.ContentNodeMapper; import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.media.MediaService; @@ -71,6 +72,7 @@ import com.condation.cms.core.configuration.properties.ExtendedSiteProperties; import com.condation.cms.core.eventbus.MessagingEventBus; import com.condation.cms.core.mail.DefaultMailService; +import com.condation.cms.core.menu.FileMenuService; import com.condation.cms.core.messages.DefaultMessageSource; import com.condation.cms.core.messaging.DefaultMessaging; import com.condation.cms.core.scheduler.SiteCronJobScheduler; @@ -235,6 +237,12 @@ public Theme loadTheme( public AuthService authService(DB db) { return new AuthService(db.getFileSystem().hostBase()); } + + @Provides + @Singleton + public MenuService menuService(DB db) { + return new FileMenuService(db.getFileSystem().hostBase()); + } @Provides @Singleton diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/SiteMenuExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/SiteMenuExtension.java index 6e90e93d4..bf796da66 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/SiteMenuExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/SiteMenuExtension.java @@ -176,4 +176,14 @@ public void list_unpublished_pages() { ) public void search_pages() { } + + @ShortCut( + id = "manage_menus", + title = "Manage menus", + permissions = {Permissions.CONTENT_EDIT}, + section = "tools", + scriptAction = @ScriptAction(module = "/manager/actions/menu/manage-menus") + ) + public void manage_menus() { + } } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteMenuEndpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteMenuEndpoints.java new file mode 100644 index 000000000..0cf19547c --- /dev/null +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteMenuEndpoints.java @@ -0,0 +1,127 @@ +package com.condation.cms.modules.ui.extensionpoints.remotemethods; + +/*- + * #%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 com.condation.cms.api.auth.Permissions; +import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.menu.Menu; +import com.condation.cms.api.menu.MenuService; +import com.condation.cms.api.ui.annotations.RemoteMethod; +import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; +import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.modules.ui.utils.json.UIGsonProvider; +import com.condation.modules.api.annotation.Extension; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +/** + * Manager RPC endpoints for all menu CRUD operations. + */ +@Slf4j +@Extension(UIRemoteMethodExtensionPoint.class) +public class RemoteMenuEndpoints extends AbstractRemoteMethodeExtension { + + @RemoteMethod(name = "menu.list", permissions = {Permissions.CONTENT_EDIT}) + public Object list(Map parameters) throws RPCException { + try { + return service().list(); + } catch (Exception ex) { + throw rpcException("Could not load menus", ex); + } + } + + @RemoteMethod(name = "menu.get", permissions = {Permissions.CONTENT_EDIT}) + public Object get(Map parameters) throws RPCException { + String id = requiredString(parameters, "id"); + try { + return service().get(id) + .orElseThrow(() -> new RPCException(1, "Menu not found: " + id)); + } catch (RPCException ex) { + throw ex; + } catch (Exception ex) { + throw rpcException("Could not load menu", ex); + } + } + + @RemoteMethod(name = "menu.create", permissions = {Permissions.CONTENT_EDIT}) + public Object create(Map parameters) throws RPCException { + try { + return service().create(menu(parameters)); + } catch (Exception ex) { + throw rpcException("Could not create menu", ex); + } + } + + @RemoteMethod(name = "menu.update", permissions = {Permissions.CONTENT_EDIT}) + public Object update(Map parameters) throws RPCException { + try { + return service().update(menu(parameters)); + } catch (Exception ex) { + throw rpcException("Could not update menu", ex); + } + } + + @RemoteMethod(name = "menu.delete", permissions = {Permissions.CONTENT_EDIT}) + public Object delete(Map parameters) throws RPCException { + String id = requiredString(parameters, "id"); + try { + return Map.of("deleted", service().delete(id)); + } catch (Exception ex) { + throw rpcException("Could not delete menu", ex); + } + } + + private MenuService service() { + return getContext() + .get(InjectorFeature.class) + .injector() + .getInstance(MenuService.class); + } + + private Menu menu(Map parameters) throws RPCException { + Object value = parameters.get("menu"); + if (value == null) { + throw new RPCException(1, "Menu is required"); + } + try { + return value instanceof Menu menu + ? menu + : UIGsonProvider.INSTANCE.fromJson(UIGsonProvider.INSTANCE.toJson(value), Menu.class); + } catch (RuntimeException ex) { + throw new RPCException(1, "Invalid menu data"); + } + } + + private String requiredString(Map parameters, String name) throws RPCException { + Object value = parameters.get(name); + if (!(value instanceof String stringValue) || stringValue.isBlank()) { + throw new RPCException(1, name + " is required"); + } + return stringValue; + } + + private RPCException rpcException(String action, Exception cause) { + log.error(action, cause); + String detail = cause.getMessage(); + return new RPCException(1, detail == null || detail.isBlank() ? action : action + ": " + detail); + } +} diff --git a/modules/ui-module/src/main/resources/manager/actions/menu/manage-menus.d.ts b/modules/ui-module/src/main/resources/manager/actions/menu/manage-menus.d.ts new file mode 100644 index 000000000..a83d0b4a2 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/menu/manage-menus.d.ts @@ -0,0 +1,21 @@ +/*- + * #%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% + */ +export declare const runAction: () => Promise; 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..d885c6d8e --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/menu/manage-menus.js @@ -0,0 +1,531 @@ +/*- + * #%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 { 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' ? 'Neue Überschrift' : type === 'divider' ? 'Trenner' : 'Neuer Menüpunkt', + 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: 'Überschrift' }, + divider: { icon: '―', name: 'Trenner' } +}; +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('Menüs verwalten'); + this.root.innerHTML = ` +
+
+
+

CondationCMS · Navigation

+

Menüs

+

Erstelle und bearbeite die Navigationen dieser Site.

+
+ +
+ +
+
Menüs werden geladen …
+
+
`; + 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 = ` +
+ + Noch keine Menüs + Lege das erste Menü für diese Site an. +
`; + return; + } + list.innerHTML = menus.map(menu => ` +
+
+
+ ${escapeHtml(menu.name)} + ${escapeHtml(menu.id)}.yaml +
+ ${countItems(menu.items)} Einträge +
+ + +
+
`).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 = '
Die Menüs konnten nicht geladen werden.
'; + this.toastError('Menüs konnten nicht geladen werden', 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('Menü konnte nicht geladen werden', error); + } + } + async removeMenu(id, card) { + if (!window.confirm(`Menü „${id}“ wirklich löschen?`)) { + return; + } + try { + if (await deleteMenu(id)) { + card.remove(); + showToast({ + title: 'Menü gelöscht', + message: `${id}.yaml wurde gelöscht.`, + type: 'success' + }); + if (!this.root.querySelector('[data-menu-id]')) { + await this.loadOverview(); + } + } + } + catch (error) { + this.toastError('Menü konnte nicht gelöscht werden', error); + } + } + showEditor() { + if (!this.menu) + return; + this.setTitle(`Menü bearbeiten · ${this.menu.name}`); + this.root.innerHTML = ` +
+
+ +
+ + ${escapeHtml(this.menu.id)}.yaml +
+
+ + + +
+
+
+ +
+
+
+

Menüstruktur

+

${escapeHtml(this.menu.name)}

+
+ +
+ +
+ + +
+
+
`; + 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)} Einträge`; + 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 = ` +
+ + ${typeMeta[item.type].icon} +
+ +
+ ${typeMeta[item.type].name} +
+ + + +
+
+ + `; + element.querySelector('.cms-menu-item__copy strong').textContent = item.label; + const detail = item.type === 'link' + ? item.url || 'Keine URL' + : item.type === 'heading' ? `${item.children.length} Unterpunkte` : 'Optische Trennung'; + element.querySelector('.cms-menu-item__copy small').textContent = + `${detail}${item.enabled ? '' : ' · Deaktiviert'}`; + 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); + 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() || 'Unbenannter Eintrag'; + 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(`Menü bearbeiten · ${this.menu.name}`); + showToast({ + title: 'Menü gespeichert', + message: `${this.menu.id}.yaml wurde aktualisiert.`, + type: 'success' + }); + } + catch (error) { + this.toastError('Menü konnte nicht gespeichert werden', 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 : 'Unbekannter Fehler', + type: 'error' + }); + } +} +export const runAction = async () => { + openModal({ + title: 'Menüs verwalten', + 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/css/manager.css b/modules/ui-module/src/main/resources/manager/css/manager.css index 50a10d23a..5cf48e293 100644 --- a/modules/ui-module/src/main/resources/manager/css/manager.css +++ b/modules/ui-module/src/main/resources/manager/css/manager.css @@ -122,6 +122,583 @@ 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-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/js/manager-inject.js b/modules/ui-module/src/main/resources/manager/js/manager-inject.js index 1edf13ab0..987c7b634 100644 --- a/modules/ui-module/src/main/resources/manager/js/manager-inject.js +++ b/modules/ui-module/src/main/resources/manager/js/manager-inject.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.d.ts index 0869c773e..0c529bf7f 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.d.ts @@ -20,6 +20,6 @@ */ export function openFileBrowser(optionsParam: any): Promise; export namespace state { - let options: null; + let options: any; let currentFolder: string; } diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts index 23eeb2258..7d3d89dcb 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts @@ -20,7 +20,7 @@ */ declare const createID: () => string; declare const utcToLocalDateTimeInputValue: (utcString: string) => string; -declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string | null; +declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string; declare function utcToLocalDateInputValue(utcString: string): string; -declare function getUTCDateFromInput(inputElement: HTMLInputElement): string | null; +declare function getUTCDateFromInput(inputElement: HTMLInputElement): string; export { createID, utcToLocalDateTimeInputValue, getUTCDateTimeFromInput, utcToLocalDateInputValue, getUTCDateFromInput }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts index e91051759..b1e393873 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts @@ -21,7 +21,7 @@ export function localizeUi(): Promise; export namespace i18n { let _locale: any; - let _cache: null; + let _cache: any; /** * Loads and merges remote localizations with defaults. */ diff --git a/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.d.ts index 125ee5274..d35ac75a0 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.d.ts @@ -20,8 +20,8 @@ */ export function openMediaBrowser(optionsParam: any): Promise; export namespace state { - let options: null; + let options: any; let currentFolder: string; - let metadataForm: null; - let metadataImage: null; + let metadataForm: any; + let metadataImage: any; } 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..f22640693 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 = ` - ${countItems(menu.items)} Einträge + ${countItems(menu.items)} items
@@ -152,8 +152,8 @@ class MenuManager { }); } catch (error) { - list.innerHTML = '
Die Menüs konnten nicht geladen werden.
'; - this.toastError('Menüs konnten nicht geladen werden', error); + list.innerHTML = '
Could not load menus.
'; + this.toastError('Could not load menus', error); } } openNewMenu() { @@ -178,19 +178,19 @@ class MenuManager { this.showEditor(); } catch (error) { - this.toastError('Menü konnte nicht geladen werden', error); + this.toastError('Could not load menu', error); } } async removeMenu(id, card) { - if (!window.confirm(`Menü „${id}“ wirklich löschen?`)) { + if (!window.confirm(`Delete menu “${id}”?`)) { return; } try { if (await deleteMenu(id)) { card.remove(); showToast({ - title: 'Menü gelöscht', - message: `${id}.yaml wurde gelöscht.`, + title: 'Menu deleted', + message: `${id}.yaml was deleted.`, type: 'success' }); if (!this.root.querySelector('[data-menu-id]')) { @@ -199,18 +199,18 @@ class MenuManager { } } catch (error) { - this.toastError('Menü konnte nicht gelöscht werden', error); + this.toastError('Could not delete menu', error); } } showEditor() { if (!this.menu) return; - this.setTitle(`Menü bearbeiten · ${this.menu.name}`); + this.setTitle(`Edit menu · ${this.menu.name}`); this.root.innerHTML = `
-

Menüstruktur

+

Menu structure

${escapeHtml(this.menu.name)}

@@ -299,7 +299,7 @@ class MenuManager { 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)} Einträge`; + `${countItems(this.menu.items)} items`; this.setupSortable(root); } renderItem(item) { @@ -308,7 +308,7 @@ class MenuManager { element.dataset.id = item.id; element.innerHTML = `
- ${typeMeta[item.type].icon} @@ -317,32 +317,32 @@ class MenuManager {
${typeMeta[item.type].name}
- - - + + +
- - - - - - + + `; element.querySelector('.cms-menu-item__copy strong').textContent = item.label; const detail = item.type === 'link' - ? item.url || 'Keine URL' - : item.type === 'heading' ? `${item.children.length} Unterpunkte` : 'Optische Trennung'; + ? 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 ? '' : ' · Deaktiviert'}`; + `${detail}${item.enabled ? '' : ' · Disabled'}`; const form = element.querySelector('form'); form.elements.namedItem('label').value = item.label; form.elements.namedItem('url').value = item.url; @@ -363,12 +363,12 @@ class MenuManager { this.updateFormFields(form, item.type); element.querySelector('[data-select-page]')?.addEventListener('click', () => { openPagePicker({ - title: 'Interne Seite auswählen', + 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 === 'Neuer Menüpunkt')) { + if (page.title && (!labelInput.value.trim() || labelInput.value === 'New menu item')) { labelInput.value = page.title; } } @@ -376,13 +376,18 @@ class MenuManager { }); element.querySelector('[data-browse-page]')?.addEventListener('click', () => { openFileBrowser({ - title: 'Interne Seite auswählen', + title: 'Select internal page', type: 'content', filter: (file) => file.directory || file.content, onSelect: (file) => { if (!file.url) return; - form.elements.namedItem('url').value = file.url; + 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; + } } }); }); @@ -396,7 +401,7 @@ class MenuManager { }); form.addEventListener('submit', event => { event.preventDefault(); - item.label = form.elements.namedItem('label').value.trim() || 'Unbenannter Eintrag'; + 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' @@ -527,15 +532,15 @@ class MenuManager { this.menu = this.isNew ? await createMenu(this.menu) : await updateMenu(this.menu); this.originalMenu = clone(this.menu); this.isNew = false; - this.setTitle(`Menü bearbeiten · ${this.menu.name}`); + this.setTitle(`Edit menu · ${this.menu.name}`); showToast({ - title: 'Menü gespeichert', - message: `${this.menu.id}.yaml wurde aktualisiert.`, + title: 'Menu saved', + message: `${this.menu.id}.yaml was updated.`, type: 'success' }); } catch (error) { - this.toastError('Menü konnte nicht gespeichert werden', error); + this.toastError('Could not save menu', error); } finally { saveButton.disabled = false; @@ -549,14 +554,14 @@ class MenuManager { toastError(title, error) { showToast({ title, - message: error instanceof Error ? error.message : 'Unbekannter Fehler', + message: error instanceof Error ? error.message : 'Unknown error', type: 'error' }); } } export const runAction = async () => { openModal({ - title: 'Menüs verwalten', + title: 'Manage menus', body: '
', fullscreen: true, showFooter: false, 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 36a37c4c6..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,8 +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"); + const title = selectedRow.getAttribute("data-cms-file-title"); const url = selectedRow.getAttribute("data-cms-file-url"); - state.options.onSelect({ uri, name, url }); + state.options.onSelect({ uri, name, title, url }); } }, onShow: async () => { @@ -109,9 +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, url }); + 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 e4c5c3405..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,7 @@ const template = Handlebars.compile(` diff --git a/modules/ui-module/src/main/ts/src/actions/menu/manage-menus.ts b/modules/ui-module/src/main/ts/src/actions/menu/manage-menus.ts index 2e1eb7d05..ee697bb39 100644 --- a/modules/ui-module/src/main/ts/src/actions/menu/manage-menus.ts +++ b/modules/ui-module/src/main/ts/src/actions/menu/manage-menus.ts @@ -55,7 +55,7 @@ const uuid = (): string => { const createItem = (type: MenuItemType = 'link'): MenuItem => ({ id: uuid(), type, - label: type === 'heading' ? 'Neue Überschrift' : type === 'divider' ? 'Trenner' : 'Neuer Menüpunkt', + label: type === 'heading' ? 'New heading' : type === 'divider' ? 'Divider' : 'New menu item', url: type === 'link' ? '/' : '', target: '_self', enabled: true, @@ -69,8 +69,8 @@ const countItems = (items: MenuItem[]): number => items.reduce( const typeMeta: Record = { link: { icon: '↗', name: 'Link' }, - heading: { icon: 'T', name: 'Überschrift' }, - divider: { icon: '―', name: 'Trenner' } + heading: { icon: 'T', name: 'Heading' }, + divider: { icon: '―', name: 'Divider' } }; class MenuManager { @@ -87,37 +87,37 @@ class MenuManager { } async showOverview(): Promise { - this.setTitle('Menüs verwalten'); + this.setTitle('Manage menus'); this.root.innerHTML = `

CondationCMS · Navigation

-

Menüs

-

Erstelle und bearbeite die Navigationen dieser Site.

+

Menus

+

Create and edit navigation menus for this site.

-
Menüs werden geladen …
+
Loading menus…
`; @@ -142,8 +142,8 @@ class MenuManager { list.innerHTML = `
- Noch keine Menüs - Lege das erste Menü für diese Site an. + No menus yet + Create the first menu for this site.
`; return; } @@ -155,13 +155,13 @@ class MenuManager { ${escapeHtml(menu.name)} ${escapeHtml(menu.id)}.yaml - ${countItems(menu.items)} Einträge + ${countItems(menu.items)} items
@@ -180,8 +180,8 @@ class MenuManager { }); }); } catch (error) { - list.innerHTML = '
Die Menüs konnten nicht geladen werden.
'; - this.toastError('Menüs konnten nicht geladen werden', error); + list.innerHTML = '
Could not load menus.
'; + this.toastError('Could not load menus', error); } } @@ -207,20 +207,20 @@ class MenuManager { this.isNew = false; this.showEditor(); } catch (error) { - this.toastError('Menü konnte nicht geladen werden', error); + this.toastError('Could not load menu', error); } } private async removeMenu(id: string, card: HTMLElement): Promise { - if (!window.confirm(`Menü „${id}“ wirklich löschen?`)) { + if (!window.confirm(`Delete menu “${id}”?`)) { return; } try { if (await deleteMenu(id)) { card.remove(); showToast({ - title: 'Menü gelöscht', - message: `${id}.yaml wurde gelöscht.`, + title: 'Menu deleted', + message: `${id}.yaml was deleted.`, type: 'success' }); if (!this.root.querySelector('[data-menu-id]')) { @@ -228,18 +228,18 @@ class MenuManager { } } } catch (error) { - this.toastError('Menü konnte nicht gelöscht werden', error); + this.toastError('Could not delete menu', error); } } private showEditor(): void { if (!this.menu) return; - this.setTitle(`Menü bearbeiten · ${this.menu.name}`); + this.setTitle(`Edit menu · ${this.menu.name}`); this.root.innerHTML = `
-

Menüstruktur

+

Menu structure

${escapeHtml(this.menu.name)}

@@ -330,7 +330,7 @@ class MenuManager { const root = this.root.querySelector('[data-menu-root]') as HTMLElement; root.replaceChildren(...this.menu.items.map(item => this.renderItem(item))); (this.root.querySelector('[data-menu-count]') as HTMLElement).textContent = - `${countItems(this.menu.items)} Einträge`; + `${countItems(this.menu.items)} items`; this.setupSortable(root); } @@ -340,7 +340,7 @@ class MenuManager { element.dataset.id = item.id; element.innerHTML = `
- ${typeMeta[item.type].icon} @@ -349,32 +349,32 @@ class MenuManager {
${typeMeta[item.type].name}
- - - + + +