diff --git a/cms-api/src/main/java/com/condation/cms/api/Constants.java b/cms-api/src/main/java/com/condation/cms/api/Constants.java index 2dbad6191..1efead7b5 100644 --- a/cms-api/src/main/java/com/condation/cms/api/Constants.java +++ b/cms-api/src/main/java/com/condation/cms/api/Constants.java @@ -142,6 +142,7 @@ public static class CacheNames { public static final String TEMPLATE = "template"; public static final String CONTENT = "content"; public static final String MEDIA = "media"; + public static final String MENU = "menu"; public static final String RESPONSE = "response"; } diff --git a/cms-api/src/main/java/com/condation/cms/api/eventbus/events/MenuChangedEvent.java b/cms-api/src/main/java/com/condation/cms/api/eventbus/events/MenuChangedEvent.java new file mode 100644 index 000000000..be7bad1f9 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/eventbus/events/MenuChangedEvent.java @@ -0,0 +1,30 @@ +package com.condation.cms.api.eventbus.events; + +/*- + * #%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 com.condation.cms.api.eventbus.Event; + +/** + * Signals that the persisted representation of a site menu has changed. + */ +public record MenuChangedEvent(String menuId) implements Event { +} 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..ac73bb37f --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/menu/MenuItem.java @@ -0,0 +1,53 @@ +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, + boolean current) { + + public MenuItem { + children = children == null ? List.of() : List.copyOf(children); + } + + public MenuItem( + String id, + String type, + String label, + String url, + String target, + boolean enabled, + List children) { + this(id, type, label, url, target, enabled, children, false); + } +} 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/CachingMenuService.java b/cms-core/src/main/java/com/condation/cms/core/menu/CachingMenuService.java new file mode 100644 index 000000000..36c087669 --- /dev/null +++ b/cms-core/src/main/java/com/condation/cms/core/menu/CachingMenuService.java @@ -0,0 +1,90 @@ +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.cache.ICache; +import com.condation.cms.api.eventbus.EventBus; +import com.condation.cms.api.eventbus.events.MenuChangedEvent; +import com.condation.cms.api.menu.Menu; +import com.condation.cms.api.menu.MenuService; +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +/** + * Site-local menu service decorator that caches loaded menu documents. + */ +public class CachingMenuService implements MenuService { + + private final MenuService delegate; + private final ICache cache; + private final EventBus eventBus; + + public CachingMenuService(MenuService delegate, ICache cache, EventBus eventBus) { + this.delegate = delegate; + this.cache = cache; + this.eventBus = eventBus; + eventBus.register(MenuChangedEvent.class, event -> cache.invalidate(event.menuId())); + } + + @Override + public synchronized List list() throws IOException { + List menus = delegate.list(); + menus.forEach(menu -> cache.put(menu.id(), menu)); + return menus; + } + + @Override + public synchronized Optional get(String id) throws IOException { + Menu cachedMenu = cache.get(id); + if (cachedMenu != null) { + return Optional.of(cachedMenu); + } + + Optional menu = delegate.get(id); + menu.ifPresent(value -> cache.put(id, value)); + return menu; + } + + @Override + public Menu create(Menu menu) throws IOException { + Menu created = delegate.create(menu); + eventBus.syncPublish(new MenuChangedEvent(created.id())); + return created; + } + + @Override + public Menu update(Menu menu) throws IOException { + Menu updated = delegate.update(menu); + eventBus.syncPublish(new MenuChangedEvent(updated.id())); + return updated; + } + + @Override + public boolean delete(String id) throws IOException { + boolean deleted = delegate.delete(id); + if (deleted) { + eventBus.syncPublish(new MenuChangedEvent(id)); + } + return deleted; + } +} 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/CachingMenuServiceTest.java b/cms-core/src/test/java/com/condation/cms/core/menu/CachingMenuServiceTest.java new file mode 100644 index 000000000..6906d740e --- /dev/null +++ b/cms-core/src/test/java/com/condation/cms/core/menu/CachingMenuServiceTest.java @@ -0,0 +1,120 @@ +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.cache.CacheManager; +import com.condation.cms.api.eventbus.events.MenuChangedEvent; +import com.condation.cms.api.menu.Menu; +import com.condation.cms.api.menu.MenuService; +import com.condation.cms.core.cache.LocalCacheProvider; +import com.condation.cms.core.eventbus.DefaultEventBus; +import java.io.IOException; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class CachingMenuServiceTest { + + @Test + void cachesReadsAndInvalidatesTheChangedMenu() throws Exception { + var delegate = new InMemoryMenuService(); + var eventBus = new DefaultEventBus(); + var cache = new LocalCacheProvider().getCache( + "menus", + new CacheManager.CacheConfig(100L, Duration.ofMinutes(5))); + var service = new CachingMenuService(delegate, cache, eventBus); + var original = new Menu("main", "Main", List.of()); + delegate.create(original); + + Assertions.assertThat(service.get("main")).contains(original); + Assertions.assertThat(service.get("main")).contains(original); + Assertions.assertThat(delegate.getCalls).isEqualTo(1); + + var updated = new Menu("main", "Updated", List.of()); + service.update(updated); + + Assertions.assertThat(service.get("main")).contains(updated); + Assertions.assertThat(delegate.getCalls).isEqualTo(2); + + eventBus.syncPublish(new MenuChangedEvent("main")); + Assertions.assertThat(service.get("main")).contains(updated); + Assertions.assertThat(delegate.getCalls).isEqualTo(3); + } + + @Test + void listPopulatesTheMenuCache() throws Exception { + var delegate = new InMemoryMenuService(); + var eventBus = new DefaultEventBus(); + var cache = new LocalCacheProvider().getCache( + "menus", + new CacheManager.CacheConfig(100L, Duration.ofMinutes(5))); + var service = new CachingMenuService(delegate, cache, eventBus); + var menu = new Menu("main", "Main", List.of()); + delegate.create(menu); + + Assertions.assertThat(service.list()).containsExactly(menu); + Assertions.assertThat(service.get("main")).contains(menu); + Assertions.assertThat(delegate.getCalls).isZero(); + } + + private static class InMemoryMenuService implements MenuService { + + private final Map menus = new LinkedHashMap<>(); + private int getCalls; + + @Override + public List list() { + return List.copyOf(menus.values()); + } + + @Override + public Optional get(String id) { + getCalls++; + return Optional.ofNullable(menus.get(id)); + } + + @Override + public Menu create(Menu menu) throws IOException { + if (menus.putIfAbsent(menu.id(), menu) != null) { + throw new IOException("Menu already exists"); + } + return menu; + } + + @Override + public Menu update(Menu menu) throws IOException { + if (menus.replace(menu.id(), menu) == null) { + throw new IOException("Menu does not exist"); + } + return menu; + } + + @Override + public boolean delete(String id) { + return menus.remove(id) != null; + } + } +} 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..ed2fe7087 --- /dev/null +++ b/cms-core/src/test/java/com/condation/cms/core/menu/FileMenuServiceTest.java @@ -0,0 +1,81 @@ +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("current") + .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 6bea7212a..384ede3c6 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,8 @@ 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.Menu; +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; @@ -76,6 +78,8 @@ 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.CachingMenuService; +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; @@ -240,6 +244,18 @@ public Theme loadTheme( public AuthService authService(DB db) { return new AuthService(db.getFileSystem().hostBase()); } + + @Provides + @Singleton + public MenuService menuService(DB db, CacheManager cacheManager, EventBus eventBus) { + ICache menuCache = cacheManager.get( + Constants.CacheNames.MENU, + new CacheManager.CacheConfig(100L, Duration.ofMinutes(5))); + return new CachingMenuService( + new FileMenuService(db.getFileSystem().hostBase()), + menuCache, + eventBus); + } @Provides @Singleton diff --git a/cms-templates/src/main/java/com/condation/cms/templates/DefaultTemplate.java b/cms-templates/src/main/java/com/condation/cms/templates/DefaultTemplate.java index 9103fd8ba..35483f7a4 100644 --- a/cms-templates/src/main/java/com/condation/cms/templates/DefaultTemplate.java +++ b/cms-templates/src/main/java/com/condation/cms/templates/DefaultTemplate.java @@ -24,6 +24,7 @@ import com.condation.cms.templates.exceptions.TagException; import com.condation.cms.templates.functions.JexlTemplateFunction; import com.condation.cms.templates.functions.impl.DateFunction; +import com.condation.cms.templates.functions.impl.MenuFunction; import com.condation.cms.templates.functions.impl.MessageFunction; import com.condation.cms.templates.functions.impl.NodeFunction; import com.condation.cms.templates.functions.impl.NodeMetaFunction; @@ -97,6 +98,8 @@ private ScopeStack createScope(Map context, DynamicConfiguration getOrCreateNamespace(scope, Constants.TemplateNamespaces.CMS) .put(MessageFunction.NAME, new JexlTemplateFunction(new MessageFunction(dynamicConfiguration.requestContext()))); + getOrCreateNamespace(scope, Constants.TemplateNamespaces.CMS) + .put(MenuFunction.NAME, new JexlTemplateFunction(new MenuFunction(dynamicConfiguration.requestContext()))); dynamicConfiguration.templateFunctions().forEach(tf -> { getOrCreateNamespace(scope, tf.namespace()).put(tf.name(), new JexlTemplateFunction(tf)); diff --git a/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/MenuFunction.java b/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/MenuFunction.java new file mode 100644 index 000000000..6a7be88da --- /dev/null +++ b/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/MenuFunction.java @@ -0,0 +1,155 @@ +package com.condation.cms.templates.functions.impl; + +/*- + * #%L + * CMS Templates + * %% + * 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.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.feature.features.RequestFeature; +import com.condation.cms.api.feature.features.SitePropertiesFeature; +import com.condation.cms.api.menu.Menu; +import com.condation.cms.api.menu.MenuItem; +import com.condation.cms.api.menu.MenuService; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.utils.HTTPUtil; +import com.condation.cms.templates.functions.TemplateFunction; +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Loads a site menu by id for use in templates. + */ +@Slf4j +@RequiredArgsConstructor +public class MenuFunction implements TemplateFunction { + + public static final String NAME = "menu"; + + private final RequestContext requestContext; + + @Override + public Object invoke(Object... params) { + if (params == null || params.length == 0 || !(params[0] instanceof String menuId)) { + return null; + } + + try { + MenuService menuService = requestContext.get(InjectorFeature.class) + .injector() + .getInstance(MenuService.class); + return menuService.get(menuId) + .map(this::withCurrentItems) + .orElse(null); + } catch (IOException | IllegalArgumentException exception) { + log.error("Could not load menu '{}'", menuId, exception); + return null; + } + } + + @Override + public String name() { + return NAME; + } + + private Menu withCurrentItems(Menu menu) { + Set currentUrls = currentUrls(); + return new Menu(menu.id(), menu.name(), copyItems(menu.items(), currentUrls)); + } + + private List copyItems(List items, Set currentUrls) { + return items.stream() + .map(item -> new MenuItem( + item.id(), + item.type(), + item.label(), + item.url(), + item.target(), + item.enabled(), + copyItems(item.children(), currentUrls), + isCurrent(item, currentUrls))) + .toList(); + } + + private boolean isCurrent(MenuItem item, Set currentUrls) { + if (!"link".equals(item.type())) { + return false; + } + String itemUrl = normalizeInternalUrl(item.url()); + return itemUrl != null && currentUrls.contains(itemUrl); + } + + private Set currentUrls() { + Set urls = new HashSet<>(); + if (requestContext.has(CurrentNodeFeature.class)) { + String nodeUrl = requestContext.get(CurrentNodeFeature.class).node().url(); + addNormalized(urls, nodeUrl); + if (nodeUrl != null && requestContext.has(SitePropertiesFeature.class)) { + addNormalized(urls, HTTPUtil.modifyUrl(nodeUrl, requestContext)); + } + } + if (requestContext.has(RequestFeature.class)) { + addNormalized(urls, requestContext.get(RequestFeature.class).uri()); + } + return urls; + } + + private void addNormalized(Set urls, String url) { + String normalized = normalizeInternalUrl(url); + if (normalized != null) { + urls.add(normalized); + } + } + + private String normalizeInternalUrl(String url) { + if (url == null) { + return null; + } + String normalized = url.trim(); + if (normalized.isEmpty() + || normalized.startsWith("//") + || normalized.contains(":")) { + return null; + } + + int queryIndex = normalized.indexOf('?'); + int fragmentIndex = normalized.indexOf('#'); + int suffixIndex = queryIndex < 0 + ? fragmentIndex + : fragmentIndex < 0 ? queryIndex : Math.min(queryIndex, fragmentIndex); + if (suffixIndex >= 0) { + normalized = normalized.substring(0, suffixIndex); + } + if (normalized.isEmpty()) { + return null; + } + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + while (normalized.length() > 1 && normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } +} diff --git a/cms-templates/src/test/java/com/condation/cms/templates/TemplateEngineFunctionsTest.java b/cms-templates/src/test/java/com/condation/cms/templates/TemplateEngineFunctionsTest.java index a329e9567..e247fc616 100644 --- a/cms-templates/src/test/java/com/condation/cms/templates/TemplateEngineFunctionsTest.java +++ b/cms-templates/src/test/java/com/condation/cms/templates/TemplateEngineFunctionsTest.java @@ -23,11 +23,16 @@ import com.condation.cms.api.annotations.Param; import com.condation.cms.api.annotations.TemplateFunction; +import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.extensions.RegisterTemplateFunctionExtensionPoint; +import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.HookSystemFeature; import com.condation.cms.api.feature.features.InjectorFeature; import com.condation.cms.api.feature.features.ModuleManagerFeature; import com.condation.cms.api.model.Parameter; +import com.condation.cms.api.menu.Menu; +import com.condation.cms.api.menu.MenuItem; +import com.condation.cms.api.menu.MenuService; import com.condation.cms.api.request.RequestContext; import com.condation.cms.hooksystem.CMSHookSystem; import com.condation.cms.hooksystem.extensions.TemplateHooks; @@ -40,6 +45,7 @@ import java.util.Date; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Function; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeAll; @@ -55,10 +61,12 @@ public class TemplateEngineFunctionsTest extends AbstractTemplateEngineTest { static DynamicConfiguration dynamicConfiguration; @BeforeAll - public void setupFunctions() { + public void setupFunctions() throws IOException { var requestContext = new RequestContext(); requestContext.add(HookSystemFeature.class, new HookSystemFeature(new CMSHookSystem())); requestContext.add(TemplateHooks.class, new TemplateHooks(requestContext)); + requestContext.add(CurrentNodeFeature.class, new CurrentNodeFeature( + new ContentNode("index.md", "/", "index.md", Map.of()))); var injectorMock = Mockito.mock(Injector.class); requestContext.add(InjectorFeature.class, new InjectorFeature(injectorMock)); @@ -67,6 +75,12 @@ public void setupFunctions() { requestContext.add(ModuleManagerFeature.class, new ModuleManagerFeature(moduleManagerMock)); Mockito.when(injectorMock.getInstance(ModuleManager.class)).thenReturn(moduleManagerMock); + var menuService = Mockito.mock(MenuService.class); + Mockito.when(menuService.get("main")).thenReturn(Optional.of(new Menu( + "main", + "Main navigation", + List.of(new MenuItem("home", "link", "Home", "/", "_self", true, List.of()))))); + Mockito.when(injectorMock.getInstance(MenuService.class)).thenReturn(menuService); Mockito.when(moduleManagerMock.extensions(RegisterTemplateFunctionExtensionPoint.class)) .thenReturn(List.of(new TestFunctions())); @@ -77,6 +91,7 @@ public void setupFunctions() { public TemplateLoader getLoader() { return new StringTemplateLoader() .add("date", "{{ date() | date('YYYY') }}") + .add("menu", "{% assign navigation = cms.menu('main') %}{{ navigation.name }}:{{ navigation.items[0].label }}:{{ navigation.items[0].current }}") // no-arg style .add("fn_noarg", "{{ ext.testfn3() }}") // context style (Parameter) @@ -99,6 +114,13 @@ public void test_date() throws IOException { Assertions.assertThat(simpleTemplate.evaluate()).isEqualToIgnoringWhitespace(year); } + @Test + void test_menu() throws IOException { + Template template = SUT.getTemplate("menu"); + Assertions.assertThat(template.evaluate(Map.of(), dynamicConfiguration)) + .isEqualToIgnoringWhitespace("Main navigation:Home:true"); + } + // --- no-arg style renders correctly --- @Test diff --git a/cms-templates/src/test/java/com/condation/cms/templates/functions/impl/MenuFunctionTest.java b/cms-templates/src/test/java/com/condation/cms/templates/functions/impl/MenuFunctionTest.java new file mode 100644 index 000000000..87d56f513 --- /dev/null +++ b/cms-templates/src/test/java/com/condation/cms/templates/functions/impl/MenuFunctionTest.java @@ -0,0 +1,71 @@ +package com.condation.cms.templates.functions.impl; + +/*- + * #%L + * CMS Templates + * %% + * 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.db.ContentNode; +import com.condation.cms.api.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.menu.Menu; +import com.condation.cms.api.menu.MenuItem; +import com.condation.cms.api.menu.MenuService; +import com.condation.cms.api.request.RequestContext; +import com.google.inject.Injector; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class MenuFunctionTest { + + @Test + void marksCurrentItemsOnRequestLocalCopies() throws Exception { + Menu cachedMenu = new Menu("main", "Main", List.of( + new MenuItem("home", "link", "Home", "/", "_self", true, List.of()), + new MenuItem("section", "heading", "Products", "", "_self", true, List.of( + new MenuItem("product", "link", "Product", "/products/?source=menu#top", "_self", true, List.of()))))); + MenuService menuService = Mockito.mock(MenuService.class); + Mockito.when(menuService.get("main")).thenReturn(Optional.of(cachedMenu)); + Injector injector = Mockito.mock(Injector.class); + Mockito.when(injector.getInstance(MenuService.class)).thenReturn(menuService); + + Menu productsMenu = invoke(injector, "/products"); + Menu homeMenu = invoke(injector, "/"); + + Assertions.assertThat(productsMenu.items().get(0).current()).isFalse(); + Assertions.assertThat(productsMenu.items().get(1).children().get(0).current()).isTrue(); + Assertions.assertThat(homeMenu.items().get(0).current()).isTrue(); + Assertions.assertThat(homeMenu.items().get(1).children().get(0).current()).isFalse(); + + Assertions.assertThat(cachedMenu.items().get(0).current()).isFalse(); + Assertions.assertThat(cachedMenu.items().get(1).children().get(0).current()).isFalse(); + } + + private Menu invoke(Injector injector, String currentUrl) { + RequestContext context = new RequestContext(); + context.add(InjectorFeature.class, new InjectorFeature(injector)); + context.add(CurrentNodeFeature.class, new CurrentNodeFeature( + new ContentNode(currentUrl, currentUrl, currentUrl, Map.of()))); + return (Menu) new MenuFunction(context).invoke("main"); + } +} 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/RemoteContentEndpointsExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java index d17e85947..74dda2aae 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java @@ -329,75 +329,87 @@ public Object getContentNode (Map parameters) { var url = (String) parameters.get("url"); - var path = URI.create(url).getPath(); - - var contextPath = getRequestContext().get(RequestFeature.class).context(); - if (!"/".equals(contextPath) && path.startsWith(contextPath)) { - path = path.replaceFirst(contextPath, ""); - } - - if (path.startsWith("/")) { - path = path.substring(1); - } + var requestUri = URI.create(url); + var path = requestUri.getPath(); + var contextPath = getRequestContext().get(RequestFeature.class).context(); + if (contextPath != null && !"/".equals(contextPath) && path.startsWith(contextPath)) { + path = path.substring(contextPath.length()); + } + if (path.isBlank()) { + path = "/"; + } else if (!path.startsWith("/")) { + path = "/" + path; + } - var contentPath = contentBase.resolve(path); - ReadOnlyFile contentFile = null; + var selectedNode = db.getContent().byUrl(path).orElse(null); + ReadOnlyFile contentFile = null; + String canonicalUri = null; + if (selectedNode != null) { + canonicalUri = selectedNode.path(); + contentFile = contentBase.resolve(canonicalUri); + } else { + var filePath = path.substring(1); + var contentPath = contentBase.resolve(filePath); if (contentPath.exists() && contentPath.isDirectory()) { - // use index.md - var tempFile = contentPath.resolve("index.md"); - if (tempFile.exists()) { - contentFile = tempFile; + var indexFile = contentPath.resolve("index.md"); + if (indexFile.exists()) { + contentFile = indexFile; } } else { - var temp = contentBase.resolve(path + ".md"); - if (temp.exists()) { - contentFile = temp; + var markdownFile = contentBase.resolve(filePath + ".md"); + if (markdownFile.exists()) { + contentFile = markdownFile; } } - - Map result = new HashMap<>(); - result.put("url", url); if (contentFile != null) { - var canonicalUri = PathUtil.toRelativeFile(contentFile, contentBase); - var selectedNode = db.getContent().byUri(canonicalUri).orElse(null); - var query = com.condation.cms.api.utils.HTTPUtil.queryParameters(URI.create(url).getQuery()); - var variantId = query.getOrDefault( - ConfigurableVariantSelector.VARIANT_QUERY_PARAMETER, - List.of() - ).stream().findFirst().orElse("").trim(); - String activeVariantId = null; - if (selectedNode != null - && !variantId.isBlank() - && !ConfigurableVariantSelector.CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId)) { - var selectedVariant = new VariantResolver(db).loadVariant(selectedNode, variantId); - if (selectedVariant.isPresent()) { - selectedNode = selectedVariant.get().node(); - activeVariantId = selectedVariant.get().id(); - } - } - if (selectedNode != null) { - contentFile = contentBase.resolve(selectedNode.uri()); - result.put("uri", selectedNode.uri()); - } else { - result.put("uri", canonicalUri); - } - result.put("canonicalUri", canonicalUri); - result.put("variantId", activeVariantId); - - var sectionEntries = db.getContent().listSectionEntries(contentFile); - Map> sectionMap = new HashMap<>(); - sectionEntries.forEach(sectionEntry -> { - String uri = sectionEntry.uri(); - String name = SectionUtil.getSectionName(sectionEntry.name()); - var index = sectionEntry.getMetaValue(Constants.MetaFields.LAYOUT_ORDER, Constants.DEFAULT_SECTION_ENTRY_LAYOUT_ORDER); - - sectionMap.computeIfAbsent(name, k -> new ArrayList<>()) - .add(new SectionEntry(sectionEntry.name(), index, "", sectionEntry.data(), uri)); - }); - result.put("sections", sectionMap); + canonicalUri = PathUtil.toRelativeFile(contentFile, contentBase); + selectedNode = db.getContent().byPath(canonicalUri).orElse(null); } + } + Map result = new HashMap<>(); + result.put("url", url); + if (contentFile == null || canonicalUri == null) { return result; + } + + var query = com.condation.cms.api.utils.HTTPUtil.queryParameters(requestUri.getQuery()); + var variantId = query.getOrDefault( + ConfigurableVariantSelector.VARIANT_QUERY_PARAMETER, + List.of() + ).stream().findFirst().orElse("").trim(); + String activeVariantId = null; + if (selectedNode != null + && !variantId.isBlank() + && !ConfigurableVariantSelector.CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId)) { + var selectedVariant = new VariantResolver(db).loadVariant(selectedNode, variantId); + if (selectedVariant.isPresent()) { + selectedNode = selectedVariant.get().node(); + activeVariantId = selectedVariant.get().id(); + } + } + if (selectedNode != null) { + contentFile = contentBase.resolve(selectedNode.path()); + result.put("uri", selectedNode.path()); + } else { + result.put("uri", canonicalUri); + } + result.put("canonicalUri", canonicalUri); + result.put("variantId", activeVariantId); + + var sectionEntries = db.getContent().listSectionEntries(contentFile); + Map> sectionMap = new HashMap<>(); + sectionEntries.forEach(sectionEntry -> { + String uri = sectionEntry.uri(); + String name = SectionUtil.getSectionName(sectionEntry.name()); + var index = sectionEntry.getMetaValue(Constants.MetaFields.LAYOUT_ORDER, Constants.DEFAULT_SECTION_ENTRY_LAYOUT_ORDER); + + sectionMap.computeIfAbsent(name, k -> new ArrayList<>()) + .add(new SectionEntry(sectionEntry.name(), index, "", sectionEntry.data(), uri)); + }); + result.put("sections", sectionMap); + + return result; } private String contentUri(Map parameters) throws RPCException { diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java index d033e0aa6..6da94a311 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java @@ -308,14 +308,15 @@ private File map (DB db, String type, ReadOnlyFile readOnlyFile) { .filter(value -> !value.isBlank()) .orElse(readOnlyFile.getFileName()); return new Content( - readOnlyFile.getFileName(), + readOnlyFile.getFileName(), + readOnlyFile.uri(), readOnlyFile.uri(), title ); } } - public record Content(String name, String uri, String title) implements File { + public record Content(String name, String uri, String url, String title) implements File { @Override public String displayName() { return title; 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/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java index 31efe9c08..a245d1e7a 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java @@ -59,7 +59,7 @@ @Extension(UIRemoteMethodExtensionPoint.class) public class RemotePageEnpoints extends AbstractRemoteMethodeExtension { - public record SearchResultDto(String uri, String title) { + public record SearchResultDto(String uri, String url, String title) { } @RemoteMethod(name = "pages.search", permissions = {Permissions.CONTENT_EDIT}) @@ -71,20 +71,17 @@ public Object searchPages (Map parameters) throws RPCException { } final DB db = getContext().get(DBFeature.class).db(); - var contentBase = db.getFileSystem().contentBase(); - var hits = db.getContent().searchByTitle(query, VariantSearchMode.ORIGINAL).stream() .map(node -> { - var contentFile = contentBase.resolve(node.uri()); - var url = PathUtil.toURL(contentFile, contentBase); - url = HTTPUtil.modifyUrl(url, context); + String url = node.url(); + String previewUrl = HTTPUtil.modifyUrl(url, context); String title = ""; if (node.data().get(Constants.MetaFields.TITLE) instanceof String titleValue) { title = titleValue; } - return new SearchResultDto(url, title); + return new SearchResultDto(previewUrl, url, title); }) .toList(); 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..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 = ` +
+
+
+

CondationCMS · Navigation

+

Menus

+

Create and edit navigation menus for this site.

+
+ +
+ +
+
Loading menus…
+
+
`; + 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 = ` +
+ + No menus yet + Create the first menu for this site. +
`; + return; + } + list.innerHTML = menus.map(menu => ` +
+
+
+ ${escapeHtml(menu.name)} + ${escapeHtml(menu.id)}.yaml +
+ ${countItems(menu.items)} items +
+ + +
+
`).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 = ` +
+
+ +
+ + ${escapeHtml(this.menu.id)}.yaml +
+
+ + + +
+
+
+ +
+
+
+

Menu structure

+

${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)} 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 = ` +
+ + ${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 || '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 ` - - - - - - - - - - ${results.map(result => ` - - - - - - `).join('')} - -
${i18n.t('page.search.columnTitle', 'Title')}${i18n.t('page.search.columnUri', 'URI')}
${result.title}${result.uri} - - ${i18n.t('page.search.loadLink', 'Load')} - -
- `; -}; -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 %}