diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryFacet.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryFacet.java new file mode 100644 index 0000000..d8fd9fa --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryFacet.java @@ -0,0 +1,94 @@ +package io.github.silentdevelopment.headdb.paper.gui.category; + +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; +import java.util.Objects; + +public record CategoryFacet( + @NotNull String categoryId, + @NotNull String title, + @NotNull String tagPrefix, + @NotNull String tagNamePrefix, + @NotNull String tagNameSuffix +) { + + public CategoryFacet { + Objects.requireNonNull(categoryId, "categoryId"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(tagPrefix, "tagPrefix"); + Objects.requireNonNull(tagNamePrefix, "tagNamePrefix"); + Objects.requireNonNull(tagNameSuffix, "tagNameSuffix"); + + categoryId = normalize(categoryId); + title = title.trim(); + tagPrefix = normalize(tagPrefix); + tagNamePrefix = tagNamePrefix.trim(); + tagNameSuffix = tagNameSuffix.trim(); + + if (categoryId.isBlank()) { + throw new IllegalArgumentException("Facet category ID cannot be blank."); + } + + if (title.isBlank()) { + throw new IllegalArgumentException("Facet title cannot be blank."); + } + + if (tagPrefix.isBlank()) { + throw new IllegalArgumentException("Facet tag prefix cannot be blank."); + } + } + + public boolean matchesTag(@NotNull String tagId) { + Objects.requireNonNull(tagId, "tagId"); + return normalize(tagId).startsWith(tagPrefix); + } + + public @NotNull String displayName(@NotNull String tagId, @NotNull String tagName) { + Objects.requireNonNull(tagId, "tagId"); + Objects.requireNonNull(tagName, "tagName"); + + String normalizedId = normalize(tagId); + String name = tagName.trim(); + + if (!matchesTag(normalizedId)) { + return name.isBlank() ? normalizedId : name; + } + + if (!tagNamePrefix.isBlank() && !tagNameSuffix.isBlank() + && name.startsWith(tagNamePrefix) + && name.endsWith(tagNameSuffix) + && name.length() > tagNamePrefix.length() + tagNameSuffix.length()) { + return name.substring(tagNamePrefix.length(), name.length() - tagNameSuffix.length()).trim(); + } + + String suffix = normalizedId.substring(tagPrefix.length()); + return humanize(suffix); + } + + private static @NotNull String normalize(@NotNull String value) { + return value.trim().toLowerCase(Locale.ROOT); + } + + private static @NotNull String humanize(@NotNull String value) { + String[] parts = value.split("-"); + StringBuilder result = new StringBuilder(); + + for (String part : parts) { + if (part.isBlank()) { + continue; + } + + if (!result.isEmpty()) { + result.append(' '); + } + + result.append(Character.toUpperCase(part.charAt(0))); + if (part.length() > 1) { + result.append(part.substring(1)); + } + } + + return result.isEmpty() ? value : result.toString(); + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryFacetMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryFacetMenu.java new file mode 100644 index 0000000..851d8ab --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryFacetMenu.java @@ -0,0 +1,398 @@ +package io.github.silentdevelopment.headdb.paper.gui.category; + +import io.github.silentdevelopment.headdb.model.Head; +import io.github.silentdevelopment.headdb.model.HeadTag; +import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials; +import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; +import io.github.silentdevelopment.headdb.paper.gui.search.SearchMenuState; +import io.github.silentdevelopment.headdb.paper.item.HeadItemIds; +import io.github.silentdevelopment.headdb.paper.message.MessageKey; +import io.github.silentdevelopment.headdb.paper.permission.Permissions; +import io.github.silentdevelopment.headdb.paper.search.SearchRequest; +import io.github.silentdevelopment.headdb.query.HeadSort; +import io.github.silentdevelopment.headdb.query.SortDirection; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class CategoryFacetMenu { + + private static final int SIZE = 54; + private static final int ROWS = 6; + private static final int SLOT_BACK = 45; + private static final int SLOT_PREVIOUS = 48; + private static final int SLOT_INFO = 49; + private static final int SLOT_NEXT = 50; + private static final int SLOT_EMPTY = 22; + private static final String ACTION_BACK = "back"; + private static final String ACTION_PREVIOUS = "previous"; + private static final String ACTION_NEXT = "next"; + private static final String ACTION_STYLE = "style:"; + private static final int[] ENTRY_SLOTS = { + 10, 11, 12, 13, 14, 15, 16, + 19, 20, 21, 22, 23, 24, 25, + 28, 29, 30, 31, 32, 33, 34, + 37, 38, 39, 40, 41, 42, 43 + }; + + private CategoryFacetMenu() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CategoryFacet facet) { + open(plugin, player, facet, 0); + } + + public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CategoryFacet facet, int requestedPage) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(facet, "facet"); + + if (!canOpen(player, facet)) { + noPermission(plugin, player); + return; + } + + open(plugin, player, facet, requestedPage, entries(plugin, player, facet)); + } + + public static void openForTag(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CategoryFacet facet, @NotNull String tagId) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(facet, "facet"); + Objects.requireNonNull(tagId, "tagId"); + + if (!canOpen(player, facet)) { + noPermission(plugin, player); + return; + } + + List entries = entries(plugin, player, facet); + int requestedPage = 0; + + for (int index = 0; index < entries.size(); index++) { + if (entries.get(index).tag().id().equalsIgnoreCase(tagId)) { + requestedPage = index / ENTRY_SLOTS.length; + break; + } + } + + open(plugin, player, facet, requestedPage, entries); + } + + public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(event, "event"); + + if (!(event.getView().getTopInventory().getHolder(false) instanceof Holder holder)) { + return false; + } + + event.setCancelled(true); + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) { + return true; + } + + ItemStack item = event.getCurrentItem(); + if (item == null || GuiMaterials.isAir(item.getType())) { + return true; + } + + Optional action = readAction(plugin, item); + if (action.isEmpty()) { + return true; + } + + Optional facet = CategoryFacetRegistry.find(holder.categoryId()); + if (facet.isEmpty()) { + plugin.guis().openBrowse(player); + return true; + } + + plugin.sounds().playGuiAction(player, action.get()); + handleAction(plugin, player, holder, facet.get(), action.get()); + return true; + } + + private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CategoryFacet facet, int requestedPage, @NotNull List entries) { + int pages = pageCount(entries.size()); + int page = Math.max(0, Math.min(requestedPage, pages - 1)); + Holder holder = new Holder(facet.categoryId(), page); + String title = plugin.guiConfig().text("title.category-styles-page", "%name% %page%/%pages%") + .replace("%name%", facet.title()) + .replace("%page%", String.valueOf(page + 1)) + .replace("%pages%", String.valueOf(Math.max(1, pages))); + Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(title, plugin.adminModes().enabled(player))); + holder.inventory(inventory); + + fillBorder(plugin, inventory); + renderEntries(plugin, player, inventory, entries, page); + + if (entries.isEmpty()) { + inventory.setItem(SLOT_EMPTY, GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("empty"), GuiItems.name("No Styles Found", NamedTextColor.RED), List.of( + GuiItems.lore("No matching styles are available.", NamedTextColor.GRAY) + ))); + } + + renderControls(plugin, inventory, entries.size(), page, pages); + player.openInventory(inventory); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN); + } + + private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull CategoryFacet facet, @NotNull String action) { + if (action.equals(ACTION_BACK)) { + plugin.guis().openSearch(player, categoryRequest(facet.categoryId()), SearchMenuState.BackTarget.BROWSE); + return; + } + + if (action.equals(ACTION_PREVIOUS)) { + open(plugin, player, facet, holder.page() - 1); + return; + } + + if (action.equals(ACTION_NEXT)) { + open(plugin, player, facet, holder.page() + 1); + return; + } + + if (!action.startsWith(ACTION_STYLE)) { + return; + } + + String tagId = action.substring(ACTION_STYLE.length()); + if (!facet.matchesTag(tagId)) { + return; + } + + plugin.guis().openSearch(player, styleRequest(facet.categoryId(), tagId), SearchMenuState.BackTarget.CATEGORY_FACET); + } + + private static void renderEntries(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull List entries, int page) { + boolean adminMode = plugin.adminModes().enabled(player); + int fromIndex = page * ENTRY_SLOTS.length; + int toIndex = Math.min(entries.size(), fromIndex + ENTRY_SLOTS.length); + int slotIndex = 0; + + for (int index = fromIndex; index < toIndex; index++) { + FacetEntry entry = entries.get(index); + ItemStack item = entryItem(plugin, entry, adminMode); + stamp(plugin, item, ACTION_STYLE + entry.tag().id()); + inventory.setItem(ENTRY_SLOTS[slotIndex], item); + slotIndex++; + } + } + + private static @NotNull ItemStack entryItem(@NotNull HeadDBPlugin plugin, @NotNull FacetEntry entry, boolean adminMode) { + ItemStack item; + + try { + item = plugin.itemFactory().create(entry.iconHead()); + } catch (IllegalArgumentException exception) { + item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("category-style")); + } + + item.editMeta(meta -> { + List lore = new ArrayList<>(); + + if (adminMode) { + lore.add(GuiItems.idDetail("Heads", entry.count())); + lore.add(GuiItems.idDetail("Tag", entry.tag().id())); + lore.add(Component.empty()); + } + + lore.add(GuiItems.lore("Click to browse this style.", NamedTextColor.GREEN)); + meta.displayName(GuiItems.name(entry.name(), NamedTextColor.GOLD)); + meta.lore(lore); + meta.getPersistentDataContainer().remove(HeadItemIds.key(plugin)); + }); + + return item; + } + + private static @NotNull List entries(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CategoryFacet facet) { + Map tags = new HashMap<>(); + + for (HeadTag tag : plugin.headRegistry().tags()) { + if (facet.matchesTag(tag.id())) { + tags.put(tag.id(), tag); + } + } + + Map counts = new HashMap<>(); + Map iconHeads = new HashMap<>(); + boolean includeHidden = plugin.adminModes().enabled(player); + + for (Head head : plugin.headRegistry().heads(includeHidden)) { + if (!head.category().equalsIgnoreCase(facet.categoryId())) { + continue; + } + + for (String tagId : head.tags()) { + if (!tags.containsKey(tagId)) { + continue; + } + + counts.merge(tagId, 1, Integer::sum); + iconHeads.putIfAbsent(tagId, head); + } + } + + List result = new ArrayList<>(); + + for (Map.Entry count : counts.entrySet()) { + HeadTag tag = tags.get(count.getKey()); + Head iconHead = iconHeads.get(count.getKey()); + + if (tag == null || iconHead == null) { + continue; + } + + result.add(new FacetEntry(tag, facet.displayName(tag.id(), tag.name()), count.getValue(), iconHead)); + } + + result.sort(Comparator.comparing(FacetEntry::name, String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(result); + } + + private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, int entryCount, int page, int pages) { + ItemStack back = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("back")); + stamp(plugin, back, ACTION_BACK); + inventory.setItem(SLOT_BACK, back); + + if (page > 0) { + ItemStack previous = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("previous")); + stamp(plugin, previous, ACTION_PREVIOUS); + inventory.setItem(SLOT_PREVIOUS, previous); + } + + inventory.setItem(SLOT_INFO, GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("info"), GuiItems.name("Info", NamedTextColor.GOLD), List.of( + GuiItems.idDetail("Styles", entryCount), + GuiItems.idDetail("Page", (page + 1) + " / " + Math.max(1, pages)) + ))); + + if (page + 1 < pages) { + ItemStack next = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("next")); + stamp(plugin, next, ACTION_NEXT); + inventory.setItem(SLOT_NEXT, next); + } + } + + private static boolean canOpen(@NotNull Player player, @NotNull CategoryFacet facet) { + return Permissions.has(player, Permissions.GUI_CATEGORY_OPEN) && Permissions.canViewCategory(player, facet.categoryId()); + } + + private static @NotNull SearchRequest categoryRequest(@NotNull String categoryId) { + return SearchRequest.lockedCategory("", Set.of(), categoryId, Set.of(), Set.of(), HeadSort.ID, SortDirection.ASCENDING, 1, 28); + } + + private static @NotNull SearchRequest styleRequest(@NotNull String categoryId, @NotNull String tagId) { + return SearchRequest.lockedCategory("", Set.of(), categoryId, Set.of(tagId), Set.of(), HeadSort.ID, SortDirection.ASCENDING, 1, 28); + } + + private static int pageCount(int entries) { + if (entries <= 0) { + return 1; + } + + return (int) Math.ceil((double) entries / (double) ENTRY_SLOTS.length); + } + + private static void fillBorder(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory) { + if (!plugin.guiConfig().filler().enabled()) { + return; + } + + Material material = GuiMaterials.itemOr(plugin.guiConfig().filler().material(), Material.BLACK_STAINED_GLASS_PANE); + ItemStack filler = GuiItems.item(material, GuiItems.mini(plugin.guiConfig().filler().name()), GuiItems.miniLore(plugin.guiConfig().filler().lore())); + + for (int slot = 0; slot < ROWS * 9; slot++) { + int row = slot / 9; + int column = slot % 9; + + if (row != 0 && row != ROWS - 1 && column != 0 && column != 8) { + continue; + } + + inventory.setItem(slot, filler); + } + } + + private static void stamp(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item, @NotNull String action) { + item.editMeta(meta -> meta.getPersistentDataContainer().set(actionKey(plugin), PersistentDataType.STRING, action)); + } + + private static @NotNull Optional readAction(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) { + if (!item.hasItemMeta()) { + return Optional.empty(); + } + + String action = item.getItemMeta().getPersistentDataContainer().get(actionKey(plugin), PersistentDataType.STRING); + if (action == null || action.isBlank()) { + return Optional.empty(); + } + + return Optional.of(action); + } + + private static @NotNull NamespacedKey actionKey(@NotNull HeadDBPlugin plugin) { + return new NamespacedKey(plugin, "category_facet_action"); + } + + private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) { + player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION)); + plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION); + } + + private record FacetEntry(@NotNull HeadTag tag, @NotNull String name, int count, @NotNull Head iconHead) {} + + private static final class Holder implements InventoryHolder { + + private final String categoryId; + private final int page; + private Inventory inventory; + + private Holder(@NotNull String categoryId, int page) { + this.categoryId = Objects.requireNonNull(categoryId, "categoryId"); + this.page = page; + } + + private @NotNull String categoryId() { + return categoryId; + } + + private int page() { + return page; + } + + private void inventory(@NotNull Inventory inventory) { + this.inventory = Objects.requireNonNull(inventory, "inventory"); + } + + @Override + public @NotNull Inventory getInventory() { + return inventory; + } + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryFacetRegistry.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryFacetRegistry.java new file mode 100644 index 0000000..5c09364 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryFacetRegistry.java @@ -0,0 +1,24 @@ +package io.github.silentdevelopment.headdb.paper.gui.category; + +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class CategoryFacetRegistry { + + private static final Map FACETS = Map.of( + "alphabet", new CategoryFacet("alphabet", "Alphabet Styles", "font-", "Font (", ")") + ); + + private CategoryFacetRegistry() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static @NotNull Optional find(@NotNull String categoryId) { + Objects.requireNonNull(categoryId, "categoryId"); + return Optional.ofNullable(FACETS.get(categoryId.trim().toLowerCase(Locale.ROOT))); + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiConfig.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiConfig.java index 024664b..c31c6d1 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiConfig.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiConfig.java @@ -57,6 +57,7 @@ public final class GuiConfig { "more-categories", "more-category", "category", + "category-style", "category-add", "category-remove", "category-name", diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java index 0ba5a1c..b0c90d4 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java @@ -9,6 +9,7 @@ import io.github.silentdevelopment.headdb.paper.gui.category.MoreCategoriesMenu; import io.github.silentdevelopment.headdb.paper.gui.category.BrowseMenu; import io.github.silentdevelopment.headdb.paper.gui.category.CollectionsMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.CategoryFacetMenu; import io.github.silentdevelopment.headdb.paper.gui.category.TagsMenu; import io.github.silentdevelopment.headdb.paper.gui.category.CreateHeadMenu; import io.github.silentdevelopment.headdb.paper.gui.category.CreateCategoryMenu; @@ -121,6 +122,10 @@ public void onInventoryClick(@NotNull InventoryClickEvent event) { return; } + if (CategoryFacetMenu.handleClick(plugin, player, event)) { + return; + } + if (CommandConfirmMenu.handleClick(plugin, player, event)) { return; } diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenuState.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenuState.java index a18840c..35fcbf0 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenuState.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenuState.java @@ -27,6 +27,7 @@ public SearchMenuState(@NotNull UUID viewerId, @NotNull SearchRequest request) { public enum BackTarget { MAIN, BROWSE, + CATEGORY_FACET, COLLECTIONS, TAGS, RESULTS diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchPageFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchPageFactory.java index 7dfd90e..4103d78 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchPageFactory.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchPageFactory.java @@ -13,6 +13,9 @@ import io.github.silentdevelopment.headdb.model.HeadTag; import io.github.silentdevelopment.headdb.model.HeadId; import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; +import io.github.silentdevelopment.headdb.paper.gui.category.CategoryFacet; +import io.github.silentdevelopment.headdb.paper.gui.category.CategoryFacetMenu; +import io.github.silentdevelopment.headdb.paper.gui.category.CategoryFacetRegistry; import io.github.silentdevelopment.headdb.paper.gui.common.GuiHeadIcons; import io.github.silentdevelopment.headdb.paper.gui.common.GuiItems; import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles; @@ -39,6 +42,7 @@ import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.Set; @SuppressWarnings("UnstableApiUsage") @@ -52,6 +56,7 @@ public final class SearchPageFactory implements PaperPageFactory page, @No set(page, reservedSlots, SLOT_SUMMARY, summaryButton(request, result, currentPage)); + categoryFacet(request).ifPresent(facet -> set( + page, + reservedSlots, + plugin.guiConfig().slot("search.category-style-browser", SLOT_CATEGORY_FACET), + categoryFacetButton(facet) + )); + if (currentPage < result.totalPages()) { set(page, reservedSlots, SLOT_NEXT, nextButton(currentPage)); } @@ -144,6 +156,22 @@ private void renderControls(@NotNull PaperPageBuilder page, @No } } + private @NotNull ItemElement categoryFacetButton(@NotNull CategoryFacet facet) { + Objects.requireNonNull(facet, "facet"); + + return GuiHeadIcons.button( + plugin, + "category_facet", + "category-style", + context -> { + Player player = player(context); + if (player != null) { + CategoryFacetMenu.open(plugin, player, facet); + } + } + ); + } + private @NotNull ItemElement sortFilterButton(@NotNull SearchRequest request) { Objects.requireNonNull(request, "request"); @@ -200,6 +228,7 @@ private void openBack(@NotNull GuiContext context, @NotNull Sea switch (target) { case BROWSE -> plugin.guis().openBrowse(player); + case CATEGORY_FACET -> openCategoryFacetBack(player, SearchState.request(context)); case COLLECTIONS -> plugin.guis().openCollections(player); case TAGS -> plugin.guis().openTags(player); case RESULTS -> context.openPage(SearchPageFactory.KEY); @@ -342,7 +371,18 @@ private void giveSelf(@NotNull GuiContext context, @NotNull Hea Objects.requireNonNull(request, "request"); if (request.categoryLocked() && request.category() != null) { - return categoryName(request.category()); + String categoryName = categoryName(request.category()); + + if (request.tags().size() == 1) { + Optional facet = CategoryFacetRegistry.find(request.category()); + String tagId = request.tags().iterator().next(); + + if (facet.isPresent() && facet.get().matchesTag(tagId)) { + return categoryName + " ยท " + facet.get().displayName(tagId, tagName(tagId)); + } + } + + return categoryName; } if (!request.query().isBlank()) { @@ -436,6 +476,44 @@ private void markFavoriteToggleItem(@NotNull ItemStack item) { return new NamespacedKey(plugin, FAVORITE_TOGGLE_MARKER); } + private @NotNull Optional categoryFacet(@NotNull SearchRequest request) { + Objects.requireNonNull(request, "request"); + + if (!request.categoryLocked() || request.category() == null) { + return Optional.empty(); + } + + if (!request.query().isBlank() || !request.ids().isEmpty() || !request.tags().isEmpty() || !request.collections().isEmpty()) { + return Optional.empty(); + } + + return CategoryFacetRegistry.find(request.category()); + } + + private void openCategoryFacetBack(@NotNull Player player, @NotNull SearchRequest request) { + Objects.requireNonNull(player, "player"); + Objects.requireNonNull(request, "request"); + + String categoryId = request.category(); + if (categoryId == null) { + plugin.guis().openBrowse(player); + return; + } + + Optional facet = CategoryFacetRegistry.find(categoryId); + if (facet.isEmpty()) { + plugin.guis().openSearch( + player, + SearchRequest.lockedCategory("", Set.of(), categoryId, Set.of(), Set.of(), io.github.silentdevelopment.headdb.query.HeadSort.ID, io.github.silentdevelopment.headdb.query.SortDirection.ASCENDING, 1, PAGE_SIZE), + SearchMenuState.BackTarget.BROWSE + ); + return; + } + + String tagId = request.tags().size() == 1 ? request.tags().iterator().next() : ""; + CategoryFacetMenu.openForTag(plugin, player, facet.get(), tagId); + } + private static @NotNull String headIdLabel(@NotNull HeadId id) { Objects.requireNonNull(id, "id"); return id.display(); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundKey.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundKey.java index f0b367f..73c79cf 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundKey.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/sound/SoundKey.java @@ -148,6 +148,10 @@ public enum SoundKey { return OPEN_INPUT; } + if (key.equals("category-style")) { + return SELECTOR_OPEN; + } + if (key.equals("category-add") || key.equals("create-category")) { return CATEGORY_CREATE; } diff --git a/headdb-platforms/headdb-paper/src/main/resources/gui.yml b/headdb-platforms/headdb-paper/src/main/resources/gui.yml index ab0cb71..9ebe1c0 100644 --- a/headdb-platforms/headdb-paper/src/main/resources/gui.yml +++ b/headdb-platforms/headdb-paper/src/main/resources/gui.yml @@ -434,6 +434,13 @@ icons: name: Category lore: - Default category button icon settings. + category-style: + type: ITEM + head-id: '' + material: BOOKSHELF + name: Browse by Style + lore: + - Browse this category by material or style. category-add: type: ITEM head-id: '' @@ -750,6 +757,7 @@ texts: title.settings: Settings title.collections-custom-page: Custom Collections %page%/%pages% title.tags-custom-page: Custom Tags %page%/%pages% + title.category-styles-page: '%name% %page%/%pages%' title.confirm-refresh: Confirm Refresh title.confirm-reload: Confirm Reload slots: @@ -767,6 +775,7 @@ slots: browse.custom-heads: 26 browse.create-head: 27 browse.search: 53 + search.category-style-browser: 4 create-head.preview: 4 create-head.use-held: 12 create-head.reset: 14 diff --git a/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryFacetTest.java b/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryFacetTest.java new file mode 100644 index 0000000..53e9387 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryFacetTest.java @@ -0,0 +1,41 @@ +package io.github.silentdevelopment.headdb.paper.gui.category; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CategoryFacetTest { + + @Test + void matchesConfiguredTagPrefix() { + CategoryFacet facet = new CategoryFacet("alphabet", "Alphabet Styles", "font-", "Font (", ")"); + + assertTrue(facet.matchesTag("font-oak")); + assertTrue(facet.matchesTag(" FONT-DIAMOND ")); + assertFalse(facet.matchesTag("alphabet-greek")); + } + + @Test + void derivesDisplayNameFromTagMetadata() { + CategoryFacet facet = new CategoryFacet("alphabet", "Alphabet Styles", "font-", "Font (", ")"); + + assertEquals("Oak", facet.displayName("font-oak", "Font (Oak)")); + assertEquals("GUI", facet.displayName("font-gui", "Font (GUI)")); + } + + @Test + void fallsBackToHumanizedTagId() { + CategoryFacet facet = new CategoryFacet("alphabet", "Alphabet Styles", "font-", "Font (", ")"); + + assertEquals("Cherry Planks", facet.displayName("font-cherry-planks", "Unexpected")); + } + + @Test + void registryExposesAlphabetFacetOnly() { + assertTrue(CategoryFacetRegistry.find("alphabet").isPresent()); + assertTrue(CategoryFacetRegistry.find(" ALPHABET ").isPresent()); + assertFalse(CategoryFacetRegistry.find("animals").isPresent()); + } +}