diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ce0bfd3..5e89025 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -149,7 +149,7 @@ jobs:
fi
grep -Fq "main: io.github.silentdevelopment.headdb.paper.HeadDBPlugin" /tmp/paper-plugin.yml
- grep -Fq "api-version: '1.21'" /tmp/paper-plugin.yml
+ grep -Fq "api-version: '26.1.2'" /tmp/paper-plugin.yml
grep -Fq 'Vault:' /tmp/paper-plugin.yml
grep -Fq 'required: false' /tmp/paper-plugin.yml
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 52f5046..15f246d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -153,7 +153,7 @@ jobs:
fi
grep -Fq "main: io.github.silentdevelopment.headdb.paper.HeadDBPlugin" /tmp/paper-plugin.yml
- grep -Fq "api-version: '1.21'" /tmp/paper-plugin.yml
+ grep -Fq "api-version: '26.1.2'" /tmp/paper-plugin.yml
grep -Fq 'Vault:' /tmp/paper-plugin.yml
grep -Fq 'required: false' /tmp/paper-plugin.yml
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
index 4eac81c..ffcab66 100644
--- a/.mvn/wrapper/maven-wrapper.properties
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -1,3 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
-distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
\ No newline at end of file
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
diff --git a/headdb-platforms/headdb-paper/pom.xml b/headdb-platforms/headdb-paper/pom.xml
index fbc45d2..3b5bade 100644
--- a/headdb-platforms/headdb-paper/pom.xml
+++ b/headdb-platforms/headdb-paper/pom.xml
@@ -34,7 +34,7 @@
3fc62454e3
3505fd1883
d78876b0e2
- 9f15982383
+ 22456310ef
403557ff80
3.53.2.0
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java
index 2dbd579..39f8430 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/HeadDBPlugin.java
@@ -18,7 +18,9 @@
import io.github.silentdevelopment.headdb.paper.item.DefaultHeadItemFactory;
import io.github.silentdevelopment.headdb.paper.item.HeadItemFactory;
import io.github.silentdevelopment.headdb.paper.local.HeadRegistry;
+import io.github.silentdevelopment.headdb.paper.local.custom.CachedCustomHeadStore;
import io.github.silentdevelopment.headdb.paper.local.custom.CustomHeadStore;
+import io.github.silentdevelopment.headdb.paper.local.override.CachedRemoteHeadOverrideStore;
import io.github.silentdevelopment.headdb.paper.local.override.RemoteHeadOverrideStore;
import io.github.silentdevelopment.headdb.paper.local.player.BukkitPlayerHeadService;
import io.github.silentdevelopment.headdb.paper.local.player.DisabledPlayerHeadService;
@@ -26,6 +28,7 @@
import io.github.silentdevelopment.headdb.paper.local.player.PlayerHeadService;
import io.github.silentdevelopment.headdb.paper.local.storage.SqliteStorageMigrator;
import io.github.silentdevelopment.headdb.paper.local.storage.StrataLocalStores;
+import io.github.silentdevelopment.headdb.paper.local.taxonomy.CustomTaxonomyService;
import io.github.silentdevelopment.headdb.paper.local.storage.NoopLocalStores;
import io.github.silentdevelopment.headdb.paper.message.Messages;
import io.github.silentdevelopment.headdb.paper.metrics.HeadDBMetrics;
@@ -35,6 +38,9 @@
import io.github.silentdevelopment.headdb.paper.runtime.RuntimeDiagnostics;
import io.github.silentdevelopment.headdb.paper.runtime.StartupChecks;
import io.github.silentdevelopment.headdb.paper.service.PaperHeadDBService;
+import io.github.silentdevelopment.headdb.paper.sound.HeadSoundService;
+import io.github.silentdevelopment.headdb.paper.sound.SoundConfig;
+import io.github.silentdevelopment.headdb.paper.sound.SoundConfigLoader;
import io.github.silentdevelopment.headdb.paper.updater.UpdateService;
import io.github.silentdevelopment.hermes.id.LocaleId;
import io.github.silentdevelopment.hermes.paper.core.Hermes;
@@ -60,7 +66,10 @@ public final class HeadDBPlugin extends JavaPlugin {
private final AdminModeService adminModes = new AdminModeService();
private FavoriteHeadService favoriteHeadService;
private CustomCategoryService customCategoryService;
+ private CustomTaxonomyService customTagService;
+ private CustomTaxonomyService customCollectionService;
private EconomyService economyService;
+ private HeadSoundService soundService;
private UpdateService updateService;
@Override
@@ -117,7 +126,10 @@ public void onDisable() {
adminModes.clear();
favoriteHeadService = null;
customCategoryService = null;
+ customTagService = null;
+ customCollectionService = null;
economyService = null;
+ soundService = null;
updateService = null;
runtime = null;
config = null;
@@ -136,17 +148,21 @@ public synchronized void reload() throws ConfigException {
PluginConfig loadedConfig = new ConfigLoader(getDataFolder().toPath()).load();
GuiConfig loadedGuiConfig = new GuiConfigLoader(this).load();
EconomyConfig loadedEconomyConfig = EconomyConfig.load(this);
+ SoundConfig loadedSoundConfig = new SoundConfigLoader(this).load();
Messages loadedMessages = createMessages(loadedConfig);
PluginRuntime createdRuntime = PluginRuntime.create(this, loadedConfig);
Path localStoreDatabase = loadedConfig.localStoreDatabase(getDataFolder().toPath());
SqliteStorageMigrator.migrate(localStoreDatabase);
- HeadRegistry createdHeadRegistry = createHeadRegistry(loadedConfig, createdRuntime);
- HeadItemFactory createdItemFactory = createItemFactory(loadedConfig);
+ CustomTaxonomyService createdCustomTagService = new CustomTaxonomyService(localStoreDatabase, "tag", "Local custom tag.");
+ CustomTaxonomyService createdCustomCollectionService = new CustomTaxonomyService(localStoreDatabase, "collection", "Local custom collection.");
+ HeadRegistry createdHeadRegistry = createHeadRegistry(loadedConfig, createdRuntime, createdCustomTagService, createdCustomCollectionService);
+ HeadItemFactory createdItemFactory = createItemFactory(loadedConfig, createdHeadRegistry);
FavoriteHeadService createdFavoriteHeadService = new FavoriteHeadService(localStoreDatabase);
CustomCategoryService createdCustomCategoryService = new CustomCategoryService(localStoreDatabase);
EconomyService createdEconomyService = EconomyService.create(this, loadedEconomyConfig);
+ HeadSoundService createdSoundService = new HeadSoundService(this, loadedSoundConfig);
GuiService createdGuiService = new GuiService(this, createdItemFactory);
UpdateService createdUpdateService = new UpdateService(this, loadedConfig);
@@ -156,8 +172,6 @@ public synchronized void reload() throws ConfigException {
StartupChecks.run(this, loadedConfig, createdRuntime);
}
- createdRuntime.start();
-
PluginRuntime previousRuntime = this.runtime;
UpdateService previousUpdateService = this.updateService;
@@ -170,7 +184,10 @@ public synchronized void reload() throws ConfigException {
this.guiService = createdGuiService;
this.favoriteHeadService = createdFavoriteHeadService;
this.customCategoryService = createdCustomCategoryService;
+ this.customTagService = createdCustomTagService;
+ this.customCollectionService = createdCustomCollectionService;
this.economyService = createdEconomyService;
+ this.soundService = createdSoundService;
this.updateService = createdUpdateService;
clearItemCache();
@@ -184,6 +201,8 @@ public synchronized void reload() throws ConfigException {
previousRuntime.close();
}
+ createdRuntime.start();
+
RuntimeDiagnostics.logRuntimeState(this, createdRuntime);
registerServices();
@@ -308,6 +327,23 @@ public synchronized void reloadGuiConfigOnly() throws ConfigException {
}
+
+ public @NotNull CustomTaxonomyService customTags() {
+ CustomTaxonomyService currentCustomTagService = customTagService;
+ if (currentCustomTagService == null) {
+ throw new IllegalStateException("HeadDB custom tag service is not initialized");
+ }
+ return currentCustomTagService;
+ }
+
+ public @NotNull CustomTaxonomyService customCollections() {
+ CustomTaxonomyService currentCustomCollectionService = customCollectionService;
+ if (currentCustomCollectionService == null) {
+ throw new IllegalStateException("HeadDB custom collection service is not initialized");
+ }
+ return currentCustomCollectionService;
+ }
+
public @NotNull EconomyService economy() {
EconomyService currentEconomyService = economyService;
if (currentEconomyService == null) {
@@ -316,6 +352,14 @@ public synchronized void reloadGuiConfigOnly() throws ConfigException {
return currentEconomyService;
}
+ public @NotNull HeadSoundService sounds() {
+ HeadSoundService currentSoundService = soundService;
+ if (currentSoundService == null) {
+ throw new IllegalStateException("HeadDB sound service is not initialized");
+ }
+ return currentSoundService;
+ }
+
public @NotNull UpdateService updater() {
UpdateService currentUpdateService = updateService;
if (currentUpdateService == null) {
@@ -354,6 +398,27 @@ public int itemCacheSize() {
return cache.size();
}
+
+ public void invalidateHeadRegistry() {
+ HeadRegistry currentHeadRegistry = headRegistry;
+
+ if (currentHeadRegistry == null) {
+ return;
+ }
+
+ currentHeadRegistry.invalidate();
+ }
+
+ public void warmHeadRegistry() {
+ HeadRegistry currentHeadRegistry = headRegistry;
+
+ if (currentHeadRegistry == null) {
+ return;
+ }
+
+ currentHeadRegistry.warm();
+ }
+
public void clearSearchCache() {
GuiService currentGuiService = guiService;
@@ -375,31 +440,34 @@ private void unregisterServices() {
getServer().getServicesManager().unregisterAll(this);
}
- private @NotNull HeadRegistry createHeadRegistry(@NotNull PluginConfig config, @NotNull PluginRuntime runtime) {
+ private @NotNull HeadRegistry createHeadRegistry(@NotNull PluginConfig config, @NotNull PluginRuntime runtime, @NotNull CustomTaxonomyService customTags, @NotNull CustomTaxonomyService customCollections) {
Objects.requireNonNull(config, "config");
Objects.requireNonNull(runtime, "runtime");
+ Objects.requireNonNull(customTags, "customTags");
+ Objects.requireNonNull(customCollections, "customCollections");
Path localStoreDatabase = config.localStoreDatabase(getDataFolder().toPath());
StrataLocalStores localStores = StrataLocalStores.sqlite(localStoreDatabase, runnable -> getServer().getAsyncScheduler().runNow(this, task -> runnable.run()));
- RemoteHeadOverrideStore overrideStore = config.remoteOverridesEnabled() ? localStores.remoteOverrides() : NoopLocalStores.remoteOverrides();
- CustomHeadStore customHeadStore = config.customHeadsEnabled() ? localStores.customHeads() : NoopLocalStores.customHeads();
+ RemoteHeadOverrideStore overrideStore = config.remoteOverridesEnabled() ? new CachedRemoteHeadOverrideStore(localStores.remoteOverrides()) : NoopLocalStores.remoteOverrides();
+ CustomHeadStore customHeadStore = config.customHeadsEnabled() ? new CachedCustomHeadStore(localStores.customHeads()) : NoopLocalStores.customHeads();
PlayerHeadCache playerHeadCache = config.playerHeadsEnabled() ? localStores.playerHeadCache(config.playerHeadCacheTtl()) : NoopLocalStores.playerHeadCache();
PlayerHeadService playerHeadService = config.playerHeadsEnabled()
? new BukkitPlayerHeadService(this, playerHeadCache, config.playerHeadCacheTtl(), config.playerHeadFailedCacheTtl(), config.playerHeadsAllowExternalLookup())
: new DisabledPlayerHeadService();
- return new HeadRegistry(runtime.database(), overrideStore, customHeadStore, playerHeadService);
+ return new HeadRegistry(runtime.database(), overrideStore, customHeadStore, playerHeadService, customTags, customCollections);
}
- private @NotNull HeadItemFactory createItemFactory(@NotNull PluginConfig config) {
+ private @NotNull HeadItemFactory createItemFactory(@NotNull PluginConfig config, @NotNull HeadRegistry registry) {
Objects.requireNonNull(config, "config");
+ Objects.requireNonNull(registry, "registry");
HeadItemFactory baseFactory = new DefaultHeadItemFactory(this);
if (!config.cacheItemEnabled()) {
return baseFactory;
}
- return new CachingHeadItemFactory(baseFactory, config.cacheItemMaxSize());
+ return new CachingHeadItemFactory(baseFactory, config.cacheItemMaxSize(), registry::revision);
}
private @NotNull Messages createMessages(@NotNull PluginConfig config) {
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/CommandResponseRenderer.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/CommandResponseRenderer.java
index 5654686..2599434 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/CommandResponseRenderer.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/CommandResponseRenderer.java
@@ -1,6 +1,7 @@
package io.github.silentdevelopment.headdb.paper.command;
import io.github.silentdevelopment.headdb.paper.HeadDBPlugin;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
import io.github.silentdevelopment.relay.command.Command;
import io.github.silentdevelopment.relay.command.Signature;
import io.github.silentdevelopment.relay.paper.text.PaperCommandResponseRenderer;
@@ -29,26 +30,31 @@ public CommandResponseRenderer(@NotNull HeadDBPlugin plugin) {
@Override
public @NotNull Component renderUnknownCommand(@NotNull CommandSender source) {
+ play(source, SoundKey.INVALID);
return plugin.messages().relayUnknownCommand(source);
}
@Override
public @NotNull Component renderNoHandler(@NotNull CommandSender source) {
+ play(source, SoundKey.INVALID);
return plugin.messages().relayNoHandler(source);
}
@Override
public @NotNull Component renderRequirementFailure(@NotNull CommandSender source, @NotNull CommandText message) {
+ play(source, SoundKey.NO_PERMISSION);
return plugin.messages().render(source, message);
}
@Override
public @NotNull Component renderAbort(@NotNull CommandSender source, @NotNull CommandText message) {
+ play(source, SoundKey.INVALID);
return plugin.messages().render(source, message);
}
@Override
public @NotNull Component renderInvalidUsage(@NotNull CommandSender source, @NotNull CommandText message, @NotNull List usages) {
+ play(source, SoundKey.INVALID);
Component component = plugin.messages().relayInvalidUsage(source, message);
if (usages.isEmpty()) {
@@ -68,6 +74,7 @@ public CommandResponseRenderer(@NotNull HeadDBPlugin plugin) {
@Override
public @NotNull Component renderInvalidUsage(@NotNull CommandSender source, @NotNull CommandText message, @NotNull String path, @NotNull Command command) {
+ play(source, SoundKey.INVALID);
Component component = plugin.messages().relayInvalidUsage(source, message);
if (command.signatures().isEmpty()) {
@@ -85,6 +92,14 @@ public CommandResponseRenderer(@NotNull HeadDBPlugin plugin) {
return component;
}
+ private void play(@NotNull CommandSender source, @NotNull SoundKey key) {
+ if (!(source instanceof Player player)) {
+ return;
+ }
+
+ plugin.sounds().play(player, key);
+ }
+
private static @NotNull Component usage(@NotNull CommandSender source, @NotNull String usage) {
Component component = Component.text(usage, NamedTextColor.GREEN);
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/Suggestions.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/Suggestions.java
index edee1f3..f256a93 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/Suggestions.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/Suggestions.java
@@ -103,6 +103,10 @@ private Suggestions() {
return context -> enumValues(context.currentToken(), SortDirection.values());
}
+ public static @NotNull SuggestionProvider taxonomyActions() {
+ return context -> matching(context.currentToken(), List.of("create", "delete"));
+ }
+
public static @NotNull SuggestionProvider pages() {
return context -> matching(context.currentToken(), List.of("1", "2", "3", "4", "5", "10"));
}
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CollectionsCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CollectionsCommand.java
index a83be16..0bdb70c 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CollectionsCommand.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CollectionsCommand.java
@@ -6,12 +6,15 @@
import io.github.silentdevelopment.headdb.paper.command.Suggestions;
import io.github.silentdevelopment.headdb.paper.command.format.ListFormatter;
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.local.taxonomy.CustomTaxonomyEntry;
import io.github.silentdevelopment.relay.argument.Argument;
import io.github.silentdevelopment.relay.command.Command;
import io.github.silentdevelopment.relay.paper.argument.PaperArgumentTypes;
import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand;
import io.github.silentdevelopment.relay.paper.command.PaperCommands;
import io.github.silentdevelopment.relay.paper.command.context.PaperCommandContext;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
import org.jetbrains.annotations.NotNull;
import java.util.Comparator;
@@ -23,6 +26,7 @@ public final class CollectionsCommand extends AbstractPaperCommand {
private static final int PAGE_SIZE = 12;
private static final Argument QUERY = Argument.optional("query", PaperArgumentTypes.STRING);
private static final Argument PAGE = Argument.optional("page", PaperArgumentTypes.STRING);
+ private static final Argument VALUE = Argument.optional("value", PaperArgumentTypes.STRING);
private final HeadDBPlugin plugin;
@@ -32,6 +36,16 @@ public CollectionsCommand(@NotNull HeadDBPlugin plugin) {
@Override
protected void handle(@NotNull PaperCommandContext context) {
+ if (context.has(QUERY) && context.get(QUERY).trim().equalsIgnoreCase("create")) {
+ create(context);
+ return;
+ }
+
+ if (context.has(QUERY) && context.get(QUERY).trim().equalsIgnoreCase("delete")) {
+ delete(context);
+ return;
+ }
+
ParsedListRequest request = request(context);
var entries = plugin.headRegistry().collections().stream()
@@ -51,13 +65,68 @@ protected void handle(@NotNull PaperCommandContext context) {
.alias("col")
.description("Lists head collections.")
.requirement(CommandRequirements.permission(Permissions.SEARCH))
- .signature(QUERY, PAGE)
- .suggest(QUERY, Suggestions.collections(plugin))
+ .signature(QUERY, PAGE, VALUE)
+ .suggest(QUERY, Suggestions.taxonomyActions())
.suggest(PAGE, Suggestions.pages())
.noArgs()
.build();
}
+
+ private void create(@NotNull PaperCommandContext context) {
+ require(context, Permissions.COLLECTION_CREATE);
+ String id = required(context, PAGE, "Usage: /hdb collections create [name]");
+ String name = context.has(VALUE) && !context.get(VALUE).trim().isBlank() ? context.get(VALUE).trim() : displayName(id);
+ java.util.UUID createdBy = context.isPlayer() ? context.player().getUniqueId() : null;
+ CustomTaxonomyEntry entry = plugin.customCollections().create(id, name, createdBy);
+ plugin.headRegistry().onLocalMutation();
+ plugin.clearSearchCache();
+ context.reply(plugin.messages().taxonomyCreated(context.sender(), "collection", entry.name(), entry.id()));
+ }
+
+
+ private void delete(@NotNull PaperCommandContext context) {
+ require(context, Permissions.COLLECTION_DELETE);
+ String id = required(context, PAGE, "Usage: /hdb collections delete ");
+ boolean deleted = plugin.customCollections().delete(id);
+ if (!deleted) {
+ context.reply(plugin.messages().taxonomyUnknown(context.sender(), "collection", id));
+ return;
+ }
+
+ plugin.headRegistry().onLocalMutation();
+ plugin.clearSearchCache();
+ context.reply(plugin.messages().taxonomyDeleted(context.sender(), "collection", id));
+ }
+
+ private static void require(@NotNull PaperCommandContext context, @NotNull String permission) {
+ if (!Permissions.has(context.sender(), permission)) {
+ throw new IllegalArgumentException("You do not have permission to do that.");
+ }
+ }
+
+ private static @NotNull String required(@NotNull PaperCommandContext context, @NotNull Argument argument, @NotNull String message) {
+ if (!context.has(argument) || context.get(argument).trim().isBlank()) {
+ throw new IllegalArgumentException(message);
+ }
+ return context.get(argument).trim();
+ }
+
+ private static @NotNull String displayName(@NotNull String id) {
+ String normalized = id.trim().replace('_', '-');
+ if (normalized.isBlank()) {
+ return "Custom Collection";
+ }
+ java.util.List words = new java.util.ArrayList<>();
+ for (String part : normalized.split("-")) {
+ if (part.isBlank()) {
+ continue;
+ }
+ words.add(Character.toUpperCase(part.charAt(0)) + part.substring(1));
+ }
+ return words.isEmpty() ? normalized : String.join(" ", words);
+ }
+
private static boolean matches(@NotNull String id, @NotNull String name, @NotNull String query) {
if (query.isBlank()) {
return true;
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CustomCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CustomCommand.java
index 9f32bc6..f72dd6a 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CustomCommand.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/CustomCommand.java
@@ -88,7 +88,7 @@ protected void handle(@NotNull PaperCommandContext context) {
private void list(@NotNull PaperCommandContext context) {
require(context, Permissions.CUSTOM_LIST);
- List heads = plugin.headRegistry().customHeads().listStored().stream().sorted(Comparator.comparing(StoredCustomHead::id)).toList();
+ List heads = plugin.headRegistry().customHeads().listStored().stream().filter(head -> !head.draft()).sorted(Comparator.comparing(StoredCustomHead::id)).toList();
int page = context.has(FIRST) ? page(context.get(FIRST)) : 1;
int from = Math.min((page - 1) * 10, heads.size());
int to = Math.min(from + 10, heads.size());
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/DebugCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/DebugCommand.java
index 2c7fa76..91167bb 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/DebugCommand.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/DebugCommand.java
@@ -8,6 +8,7 @@
import io.github.silentdevelopment.headdb.paper.runtime.BuildInfo;
import io.github.silentdevelopment.headdb.paper.runtime.PlatformRequirements;
import io.github.silentdevelopment.headdb.paper.runtime.RefreshState;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
import io.github.silentdevelopment.headdb.paper.updater.UpdateCheckResult;
import io.github.silentdevelopment.relay.command.Command;
import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand;
@@ -35,6 +36,10 @@ public DebugCommand(@NotNull HeadDBPlugin plugin) {
@Override
protected void handle(@NotNull PaperCommandContext context) {
+ if (context.isPlayer()) {
+ plugin.sounds().play(context.player(), SoundKey.DEBUG);
+ }
+
BuildInfo buildInfo = BuildInfo.read(plugin);
DatabaseStatus status = plugin.runtime().database().status();
DatabaseStats remoteStats = plugin.runtime().database().stats();
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/GiveCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/GiveCommand.java
index 399bf8d..7fc2457 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/GiveCommand.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/GiveCommand.java
@@ -7,6 +7,7 @@
import io.github.silentdevelopment.headdb.paper.command.Suggestions;
import io.github.silentdevelopment.headdb.paper.command.search.SearchParser;
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
import io.github.silentdevelopment.relay.argument.Argument;
import io.github.silentdevelopment.relay.command.Command;
import io.github.silentdevelopment.relay.paper.argument.PaperArgumentTypes;
@@ -43,6 +44,7 @@ protected void handle(@NotNull PaperCommandContext context) {
request = request(context);
} catch (IllegalArgumentException exception) {
context.reply(plugin.messages().invalidArgument(context.sender(), exception.getMessage()));
+ play(context, SoundKey.INVALID);
return;
}
@@ -152,17 +154,20 @@ protected void handle(@NotNull PaperCommandContext context) {
if (normalizedTargetName.isEmpty()) {
context.reply(plugin.messages().targetEmpty(context.sender()));
+ play(context, SoundKey.INVALID);
return null;
}
Player target = Bukkit.getPlayerExact(normalizedTargetName);
if (target == null) {
context.reply(plugin.messages().playerNotOnline(context.sender(), normalizedTargetName));
+ play(context, SoundKey.INVALID);
return null;
}
if (!Permissions.canGiveTo(context.sender(), target)) {
context.reply(plugin.messages().noGiveOthers(context.sender()));
+ play(context, SoundKey.NO_PERMISSION);
return null;
}
@@ -181,28 +186,43 @@ private void give(@NotNull PaperCommandContext context, @NotNull Player target,
item = plugin.itemFactory().create(head);
} catch (IllegalArgumentException exception) {
context.reply(plugin.messages().invalidArgument(context.sender(), exception.getMessage()));
+ play(context, SoundKey.INVALID);
return;
}
if (target.getInventory().firstEmpty() == -1) {
context.reply(plugin.messages().giveInventoryFull(context.sender(), target));
+ play(context, SoundKey.INVALID);
return;
}
Map remaining = target.getInventory().addItem(item);
if (!remaining.isEmpty()) {
context.reply(plugin.messages().giveInventoryFull(context.sender(), target));
+ play(context, SoundKey.INVALID);
return;
}
}
context.reply(plugin.messages().giveSuccess(context.sender(), head, target));
+ plugin.sounds().play(target, SoundKey.GIVE_HEAD);
+ if (context.isPlayer() && !context.player().equals(target)) {
+ plugin.sounds().play(context.player(), SoundKey.GIVE_HEAD);
+ }
if (!context.sender().equals(target)) {
target.sendMessage(plugin.messages().giveReceived(target, head));
}
}
+ private void play(@NotNull PaperCommandContext context, @NotNull SoundKey key) {
+ if (!context.isPlayer()) {
+ return;
+ }
+
+ plugin.sounds().play(context.player(), key);
+ }
+
private static @Nullable HeadId parseOptionalHeadId(@NotNull String raw) {
try {
return SearchParser.headId(raw);
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/PlayerCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/PlayerCommand.java
index ef21886..6bd7f0b 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/PlayerCommand.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/PlayerCommand.java
@@ -5,6 +5,7 @@
import io.github.silentdevelopment.headdb.paper.command.CommandRequirements;
import io.github.silentdevelopment.headdb.paper.command.Suggestions;
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
import io.github.silentdevelopment.relay.argument.Argument;
import io.github.silentdevelopment.relay.command.Command;
import io.github.silentdevelopment.relay.paper.argument.PaperArgumentTypes;
@@ -44,6 +45,7 @@ protected void handle(@NotNull PaperCommandContext context) {
parsedTarget = parseTarget(context);
} catch (IllegalArgumentException exception) {
context.reply(plugin.messages().invalidArgument(context.sender(), exception.getMessage()));
+ play(context, SoundKey.INVALID);
return;
}
@@ -54,6 +56,7 @@ protected void handle(@NotNull PaperCommandContext context) {
if (!Permissions.canPlayerHeadFor(context.sender(), target)) {
context.reply(plugin.messages().render(context.sender(), io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ play(context, SoundKey.NO_PERMISSION);
return;
}
@@ -61,6 +64,7 @@ protected void handle(@NotNull PaperCommandContext context) {
plugin.headRegistry().playerHeads().resolve(lookup).whenComplete((head, throwable) -> plugin.getServer().getGlobalRegionScheduler().execute(plugin, () -> {
if (throwable != null) {
context.reply(Component.text("Could not resolve player head: ", NamedTextColor.RED).append(Component.text(message(throwable), NamedTextColor.GRAY)));
+ play(context, SoundKey.INVALID);
return;
}
@@ -71,11 +75,16 @@ protected void handle(@NotNull PaperCommandContext context) {
for (int index = 0; index < parsedTarget.amount(); index++) {
if (!give(target, head)) {
context.reply(plugin.messages().giveInventoryFull(context.sender(), target));
+ play(context, SoundKey.INVALID);
return;
}
}
context.reply(plugin.messages().giveSuccess(context.sender(), head, target));
+ plugin.sounds().play(target, SoundKey.PLAYER_HEAD);
+ if (context.isPlayer() && !context.player().equals(target)) {
+ plugin.sounds().play(context.player(), SoundKey.PLAYER_HEAD);
+ }
}));
}
@@ -119,12 +128,14 @@ protected void handle(@NotNull PaperCommandContext context) {
return context.player();
}
context.reply(Component.text("Usage: /hdb player [amount]", NamedTextColor.RED));
+ play(context, SoundKey.INVALID);
return null;
}
Player target = Bukkit.getPlayerExact(targetName.trim());
if (target == null) {
context.reply(plugin.messages().playerNotOnline(context.sender(), targetName));
+ play(context, SoundKey.INVALID);
return null;
}
return target;
@@ -139,6 +150,14 @@ private boolean give(@NotNull Player target, @NotNull Head head) {
return remaining.isEmpty();
}
+ private void play(@NotNull PaperCommandContext context, @NotNull SoundKey key) {
+ if (!context.isPlayer()) {
+ return;
+ }
+
+ plugin.sounds().play(context.player(), key);
+ }
+
private static boolean isAmount(@NotNull String raw) {
try {
parseAmount(raw);
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RandomCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RandomCommand.java
index d8d7bfc..9631519 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RandomCommand.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RandomCommand.java
@@ -7,6 +7,7 @@
import io.github.silentdevelopment.headdb.paper.item.HeadItemFactory;
import io.github.silentdevelopment.headdb.paper.message.MessageKey;
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
import io.github.silentdevelopment.headdb.query.HeadQuery;
import io.github.silentdevelopment.headdb.query.HeadQueryResult;
import io.github.silentdevelopment.headdb.query.HeadSort;
@@ -50,6 +51,7 @@ protected void handle(@NotNull PaperCommandContext context) {
request = request(context);
} catch (IllegalArgumentException exception) {
context.reply(plugin.messages().invalidArgument(context.sender(), exception.getMessage()));
+ play(context, SoundKey.INVALID);
return;
}
@@ -60,6 +62,7 @@ protected void handle(@NotNull PaperCommandContext context) {
if (!Permissions.canGiveTo(context.sender(), target)) {
context.reply(plugin.messages().render(context.sender(), MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ play(context, SoundKey.NO_PERMISSION);
return;
}
@@ -67,6 +70,7 @@ protected void handle(@NotNull PaperCommandContext context) {
Optional optionalHead = randomHead(request.category());
if (optionalHead.isEmpty()) {
context.reply(plugin.messages().randomEmpty(context.sender()));
+ play(context, SoundKey.INVALID);
return;
}
@@ -76,10 +80,15 @@ protected void handle(@NotNull PaperCommandContext context) {
if (!give(target, optionalHead.get())) {
context.reply(plugin.messages().giveInventoryFull(context.sender(), target));
+ play(context, SoundKey.INVALID);
return;
}
context.reply(plugin.messages().giveSuccess(context.sender(), optionalHead.get(), target));
+ plugin.sounds().play(target, SoundKey.GIVE_HEAD);
+ if (context.isPlayer() && !context.player().equals(target)) {
+ plugin.sounds().play(context.player(), SoundKey.GIVE_HEAD);
+ }
}
}
@@ -138,6 +147,7 @@ private boolean give(@NotNull Player target, @NotNull Head head) {
}
context.reply(plugin.messages().randomConsoleUsage(context.sender()));
+ play(context, SoundKey.INVALID);
return null;
}
@@ -147,6 +157,7 @@ private boolean give(@NotNull Player target, @NotNull Head head) {
}
context.reply(plugin.messages().playerNotOnline(context.sender(), playerName));
+ play(context, SoundKey.INVALID);
return null;
}
@@ -186,6 +197,14 @@ private void consume(@NotNull PaperCommandContext context, @NotNull RandomReques
throw new IllegalArgumentException("Could not parse random command argument: " + value);
}
+ private void play(@NotNull PaperCommandContext context, @NotNull SoundKey key) {
+ if (!context.isPlayer()) {
+ return;
+ }
+
+ plugin.sounds().play(context.player(), key);
+ }
+
private static boolean isAmount(@NotNull String value) {
try {
Integer.parseInt(value);
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RefreshCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RefreshCommand.java
index 867081b..26c80d9 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RefreshCommand.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/RefreshCommand.java
@@ -3,6 +3,7 @@
import io.github.silentdevelopment.headdb.paper.HeadDBPlugin;
import io.github.silentdevelopment.headdb.paper.command.CommandRequirements;
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
import io.github.silentdevelopment.relay.command.Command;
import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand;
import io.github.silentdevelopment.relay.paper.command.PaperCommands;
@@ -29,6 +30,9 @@ protected void handle(@NotNull PaperCommandContext context) {
}
context.reply(plugin.messages().refreshStarted(context.sender()));
+ if (context.isPlayer()) {
+ plugin.sounds().play(context.player(), SoundKey.REFRESH);
+ }
}
@Override
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/ReloadCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/ReloadCommand.java
index fad89ed..09fa05d 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/ReloadCommand.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/ReloadCommand.java
@@ -3,6 +3,7 @@
import io.github.silentdevelopment.headdb.paper.HeadDBPlugin;
import io.github.silentdevelopment.headdb.paper.command.CommandRequirements;
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
import io.github.silentdevelopment.relay.command.Command;
import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand;
import io.github.silentdevelopment.relay.paper.command.PaperCommands;
@@ -33,6 +34,9 @@ protected void handle(@NotNull PaperCommandContext context) {
}
context.reply(plugin.messages().reloadSuccess(context.sender()));
+ if (context.isPlayer()) {
+ plugin.sounds().play(context.player(), SoundKey.RELOAD);
+ }
}
@Override
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/TagsCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/TagsCommand.java
index 7f15a65..e694926 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/TagsCommand.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/TagsCommand.java
@@ -6,12 +6,15 @@
import io.github.silentdevelopment.headdb.paper.command.Suggestions;
import io.github.silentdevelopment.headdb.paper.command.format.ListFormatter;
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.local.taxonomy.CustomTaxonomyEntry;
import io.github.silentdevelopment.relay.argument.Argument;
import io.github.silentdevelopment.relay.command.Command;
import io.github.silentdevelopment.relay.paper.argument.PaperArgumentTypes;
import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand;
import io.github.silentdevelopment.relay.paper.command.PaperCommands;
import io.github.silentdevelopment.relay.paper.command.context.PaperCommandContext;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
import org.jetbrains.annotations.NotNull;
import java.util.Comparator;
@@ -23,6 +26,7 @@ public final class TagsCommand extends AbstractPaperCommand {
private static final int PAGE_SIZE = 12;
private static final Argument QUERY = Argument.optional("query", PaperArgumentTypes.STRING);
private static final Argument PAGE = Argument.optional("page", PaperArgumentTypes.STRING);
+ private static final Argument VALUE = Argument.optional("value", PaperArgumentTypes.STRING);
private final HeadDBPlugin plugin;
@@ -32,6 +36,16 @@ public TagsCommand(@NotNull HeadDBPlugin plugin) {
@Override
protected void handle(@NotNull PaperCommandContext context) {
+ if (context.has(QUERY) && context.get(QUERY).trim().equalsIgnoreCase("create")) {
+ create(context);
+ return;
+ }
+
+ if (context.has(QUERY) && context.get(QUERY).trim().equalsIgnoreCase("delete")) {
+ delete(context);
+ return;
+ }
+
ParsedListRequest request = request(context);
var entries = plugin.headRegistry().tags().stream()
@@ -51,13 +65,68 @@ protected void handle(@NotNull PaperCommandContext context) {
.alias("t")
.description("Lists tags.")
.requirement(CommandRequirements.permission(Permissions.SEARCH))
- .signature(QUERY, PAGE)
- .suggest(QUERY, Suggestions.tags(plugin))
+ .signature(QUERY, PAGE, VALUE)
+ .suggest(QUERY, Suggestions.taxonomyActions())
.suggest(PAGE, Suggestions.pages())
.noArgs()
.build();
}
+
+ private void create(@NotNull PaperCommandContext context) {
+ require(context, Permissions.TAG_CREATE);
+ String id = required(context, PAGE, "Usage: /hdb tags create [name]");
+ String name = context.has(VALUE) && !context.get(VALUE).trim().isBlank() ? context.get(VALUE).trim() : displayName(id);
+ java.util.UUID createdBy = context.isPlayer() ? context.player().getUniqueId() : null;
+ CustomTaxonomyEntry entry = plugin.customTags().create(id, name, createdBy);
+ plugin.headRegistry().onLocalMutation();
+ plugin.clearSearchCache();
+ context.reply(plugin.messages().taxonomyCreated(context.sender(), "tag", entry.name(), entry.id()));
+ }
+
+
+ private void delete(@NotNull PaperCommandContext context) {
+ require(context, Permissions.TAG_DELETE);
+ String id = required(context, PAGE, "Usage: /hdb tags delete ");
+ boolean deleted = plugin.customTags().delete(id);
+ if (!deleted) {
+ context.reply(plugin.messages().taxonomyUnknown(context.sender(), "tag", id));
+ return;
+ }
+
+ plugin.headRegistry().onLocalMutation();
+ plugin.clearSearchCache();
+ context.reply(plugin.messages().taxonomyDeleted(context.sender(), "tag", id));
+ }
+
+ private static void require(@NotNull PaperCommandContext context, @NotNull String permission) {
+ if (!Permissions.has(context.sender(), permission)) {
+ throw new IllegalArgumentException("You do not have permission to do that.");
+ }
+ }
+
+ private static @NotNull String required(@NotNull PaperCommandContext context, @NotNull Argument argument, @NotNull String message) {
+ if (!context.has(argument) || context.get(argument).trim().isBlank()) {
+ throw new IllegalArgumentException(message);
+ }
+ return context.get(argument).trim();
+ }
+
+ private static @NotNull String displayName(@NotNull String id) {
+ String normalized = id.trim().replace('_', '-');
+ if (normalized.isBlank()) {
+ return "Custom Tag";
+ }
+ java.util.List words = new java.util.ArrayList<>();
+ for (String part : normalized.split("-")) {
+ if (part.isBlank()) {
+ continue;
+ }
+ words.add(Character.toUpperCase(part.charAt(0)) + part.substring(1));
+ }
+ return words.isEmpty() ? normalized : String.join(" ", words);
+ }
+
private static boolean matches(@NotNull String id, @NotNull String name, @NotNull String query) {
if (query.isBlank()) {
return true;
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VerifyCommand.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VerifyCommand.java
index 6977a14..d9cf33a 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VerifyCommand.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/command/subcommand/VerifyCommand.java
@@ -6,6 +6,7 @@
import io.github.silentdevelopment.headdb.paper.command.CommandRequirements;
import io.github.silentdevelopment.headdb.paper.message.MessageKey;
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
import io.github.silentdevelopment.relay.command.Command;
import io.github.silentdevelopment.relay.paper.command.AbstractPaperCommand;
import io.github.silentdevelopment.relay.paper.command.PaperCommands;
@@ -29,6 +30,9 @@ public VerifyCommand(@NotNull HeadDBPlugin plugin) {
protected void handle(@NotNull PaperCommandContext context) {
if (!Permissions.has(context.sender(), Permissions.VERIFY)) {
context.reply(plugin.messages().render(context.sender(), MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ if (context.isPlayer()) {
+ plugin.sounds().play(context.player(), SoundKey.NO_PERMISSION);
+ }
return;
}
@@ -45,6 +49,9 @@ protected void handle(@NotNull PaperCommandContext context) {
}
context.reply(plugin.messages().verifyStarted(context.sender()));
+ if (context.isPlayer()) {
+ plugin.sounds().play(context.player(), SoundKey.VERIFY);
+ }
}
@Override
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java
index 6ad7303..043e7fb 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java
@@ -3,17 +3,23 @@
import io.github.silentdevelopment.headdb.model.Head;
import io.github.silentdevelopment.headdb.paper.HeadDBPlugin;
import net.milkbowl.vault.economy.Economy;
+import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.plugin.RegisteredServiceProvider;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Locale;
import java.util.Objects;
public final class EconomyService {
private final HeadDBPlugin plugin;
- private final EconomyConfig config;
+ private EconomyConfig config;
private final EconomyProvider provider;
private EconomyService(@NotNull HeadDBPlugin plugin, @NotNull EconomyConfig config, @NotNull EconomyProvider provider) {
@@ -107,14 +113,51 @@ public boolean charge(@NotNull Player player, @NotNull Head head, int amount, @N
if (withdrawal.success()) {
player.sendMessage(plugin.messages().economyPurchased(player, head, formattedPrice));
+ plugin.sounds().play(player, SoundKey.ECONOMY_PURCHASE);
return true;
}
player.sendMessage(plugin.messages().economyInvalidFunds(player, head, formattedPrice));
+ plugin.sounds().play(player, SoundKey.ECONOMY_INSUFFICIENT_FUNDS);
return false;
}
+ public void setHeadPrice(@NotNull io.github.silentdevelopment.headdb.model.HeadId id, double price) {
+ Objects.requireNonNull(id, "id");
+ setPrice("prices.heads." + id.display().trim().toLowerCase(Locale.ROOT), price);
+ }
+
+ public void setCustomCategoryPrice(@NotNull String categoryId, double price) {
+ Objects.requireNonNull(categoryId, "categoryId");
+ setPrice("prices.custom-categories." + categoryId.trim().toLowerCase(Locale.ROOT), price);
+ }
+
public @NotNull String format(double amount) {
return provider.format(amount);
}
+
+ private void setPrice(@NotNull String path, double price) {
+ if (price < 0.0D) {
+ throw new IllegalArgumentException("Price cannot be negative.");
+ }
+
+ Path file = plugin.getDataFolder().toPath().resolve("economy.yml");
+ try {
+ Path parent = file.toAbsolutePath().normalize().getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
+
+ YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file.toFile());
+ if (price <= 0.0D) {
+ yaml.set(path, null);
+ } else {
+ yaml.set(path, price);
+ }
+ yaml.save(file.toFile());
+ config = EconomyConfig.load(plugin);
+ } catch (IOException exception) {
+ throw new IllegalStateException("Failed to update economy.yml.", exception);
+ }
+ }
}
\ No newline at end of file
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/GuiService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/GuiService.java
index 23eebc9..93a537b 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/GuiService.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/GuiService.java
@@ -5,6 +5,9 @@
import io.github.silentdevelopment.grafik.paper.core.PaperGrafiks;
import io.github.silentdevelopment.headdb.paper.HeadDBPlugin;
import io.github.silentdevelopment.headdb.paper.gui.main.MainMenu;
+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.TagsMenu;
import io.github.silentdevelopment.headdb.paper.gui.category.MoreCategoriesMenu;
import io.github.silentdevelopment.headdb.paper.gui.favorites.FavoritesMenu;
import io.github.silentdevelopment.headdb.paper.gui.local.LocalHeadListMenu;
@@ -15,6 +18,7 @@
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
import io.github.silentdevelopment.headdb.paper.search.SearchRequest;
import io.github.silentdevelopment.headdb.paper.search.SearchResultCache;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
@@ -26,6 +30,7 @@ public final class GuiService {
private final PaperGrafik grafik;
private final DynamicGui mainGui;
private final DynamicGui searchGui;
+ private final DynamicGui advancedSearchGui;
private final SearchResultCache searchResultCache;
public GuiService(@NotNull HeadDBPlugin plugin, @NotNull HeadItemFactory itemFactory) {
@@ -36,6 +41,7 @@ public GuiService(@NotNull HeadDBPlugin plugin, @NotNull HeadItemFactory itemFac
this.grafik = PaperGrafiks.create(plugin);
this.mainGui = grafik.prepare(new MainMenu(plugin));
this.searchGui = grafik.prepare(new SearchMenu(plugin, itemFactory, searchResultCache));
+ this.advancedSearchGui = grafik.prepare(SearchMenu.advanced(plugin, itemFactory, searchResultCache));
}
public void openMain(@NotNull Player player) {
@@ -43,32 +49,109 @@ public void openMain(@NotNull Player player) {
if (!Permissions.has(player, Permissions.GUI_MAIN)) {
player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, SoundKey.NO_PERMISSION);
return;
}
grafik.open(mainGui, new MenuState(player.getUniqueId()), player);
+ plugin.sounds().play(player, SoundKey.MENU_OPEN);
}
- public void openSearch(@NotNull Player player, @NotNull SearchRequest request) {
+ public void openBrowse(@NotNull Player player) {
+ Objects.requireNonNull(player, "player");
+
+ if (!Permissions.has(player, Permissions.GUI_BROWSE_MENU)) {
+ player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, SoundKey.NO_PERMISSION);
+ return;
+ }
+
+ BrowseMenu.open(plugin, player);
+ }
+
+ public void openAdvancedSearch(@NotNull Player player) {
+ openAdvancedSearch(player, new SearchRequest("", java.util.Set.of(), java.util.Set.of(), java.util.Set.of(), java.util.Set.of(), io.github.silentdevelopment.headdb.query.HeadSort.RELEVANCE, io.github.silentdevelopment.headdb.query.SortDirection.DESCENDING, 1, 28, false), SearchMenuState.BackTarget.MAIN);
+ }
+
+ public void openAdvancedSearch(@NotNull Player player, @NotNull SearchRequest request, @NotNull SearchMenuState.BackTarget backTarget) {
Objects.requireNonNull(player, "player");
Objects.requireNonNull(request, "request");
+ Objects.requireNonNull(backTarget, "backTarget");
- if (request.category() != null) {
- if (!Permissions.has(player, Permissions.GUI_CATEGORY_OPEN) || !Permissions.canViewCategory(player, request.category())) {
- player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
- return;
- }
- } else if (!Permissions.has(player, Permissions.GUI_BROWSE) || !Permissions.canViewAllCategories(player)) {
+ if (!Permissions.has(player, Permissions.GUI_SEARCH_ADVANCED) || !Permissions.has(player, Permissions.GUI_FILTER)) {
player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, SoundKey.NO_PERMISSION);
return;
}
- if (!request.query().isBlank() && !Permissions.has(player, Permissions.SEARCH)) {
+ grafik.open(advancedSearchGui, new SearchMenuState(player.getUniqueId(), request, backTarget, backTarget), player);
+ plugin.sounds().play(player, SoundKey.MENU_OPEN);
+ }
+
+ public void openCollections(@NotNull Player player) {
+ Objects.requireNonNull(player, "player");
+
+ if (!Permissions.has(player, Permissions.GUI_COLLECTIONS)) {
+ player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, SoundKey.NO_PERMISSION);
+ return;
+ }
+
+ CollectionsMenu.open(plugin, player);
+ }
+
+ public void openTags(@NotNull Player player) {
+ Objects.requireNonNull(player, "player");
+
+ if (!Permissions.has(player, Permissions.GUI_TAGS)) {
+ player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, SoundKey.NO_PERMISSION);
+ return;
+ }
+
+ TagsMenu.open(plugin, player);
+ }
+
+ public void openSearch(@NotNull Player player, @NotNull SearchRequest request) {
+ openSearch(player, request, SearchMenuState.BackTarget.MAIN);
+ }
+
+ public void openSearch(@NotNull Player player, @NotNull SearchRequest request, @NotNull SearchMenuState.BackTarget backTarget) {
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(request, "request");
+ Objects.requireNonNull(backTarget, "backTarget");
+
+ if (!canOpenSearch(player, request)) {
player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, SoundKey.NO_PERMISSION);
return;
}
- grafik.open(searchGui, new SearchMenuState(player.getUniqueId(), request), player);
+ grafik.open(searchGui, new SearchMenuState(player.getUniqueId(), request, backTarget, SearchMenuState.BackTarget.RESULTS), player);
+ plugin.sounds().play(player, SoundKey.MENU_OPEN);
+ }
+
+ private boolean canOpenSearch(@NotNull Player player, @NotNull SearchRequest request) {
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(request, "request");
+
+ if (!request.query().isBlank() && !Permissions.has(player, Permissions.SEARCH)) {
+ return false;
+ }
+
+ if (request.categoryLocked()) {
+ return request.category() != null && Permissions.has(player, Permissions.GUI_CATEGORY_OPEN) && Permissions.canViewCategory(player, request.category());
+ }
+
+ if (!request.categories().isEmpty()) {
+ return Permissions.has(player, Permissions.GUI_CATEGORY_OPEN) && request.categories().stream().allMatch(category -> Permissions.canViewCategory(player, category));
+ }
+
+ if (request.isEmpty()) {
+ return Permissions.has(player, Permissions.GUI_BROWSE) && Permissions.canViewAllCategories(player);
+ }
+
+ return Permissions.has(player, Permissions.GUI_BROWSE) || Permissions.has(player, Permissions.GUI_SEARCH);
}
@@ -77,6 +160,7 @@ public void openPlayerHeads(@NotNull Player player) {
if (!Permissions.has(player, Permissions.GUI_PLAYER_HEADS)) {
player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, SoundKey.NO_PERMISSION);
return;
}
@@ -88,6 +172,7 @@ public void openFavorites(@NotNull Player player) {
if (!Permissions.has(player, Permissions.GUI_FAVORITES)) {
player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, SoundKey.NO_PERMISSION);
return;
}
@@ -99,6 +184,7 @@ public void openMoreCategories(@NotNull Player player) {
if (!Permissions.has(player, Permissions.GUI_MORE_CATEGORIES)) {
player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, SoundKey.NO_PERMISSION);
return;
}
@@ -110,6 +196,7 @@ public void openCustomHeads(@NotNull Player player) {
if (!Permissions.has(player, Permissions.GUI_CUSTOM_HEADS)) {
player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, SoundKey.NO_PERMISSION);
return;
}
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/BrowseMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/BrowseMenu.java
new file mode 100644
index 0000000..51bb7d8
--- /dev/null
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/BrowseMenu.java
@@ -0,0 +1,600 @@
+package io.github.silentdevelopment.headdb.paper.gui.category;
+
+import io.github.silentdevelopment.headdb.model.Head;
+import io.github.silentdevelopment.headdb.model.HeadCategory;
+import io.github.silentdevelopment.headdb.model.HeadId;
+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.config.GuiButtonEditorMenu;
+import io.github.silentdevelopment.headdb.paper.gui.config.GuiIconConfig;
+import io.github.silentdevelopment.headdb.paper.gui.hidden.HiddenHeadsMenu;
+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 net.kyori.adventure.text.format.TextDecoration;
+import org.bukkit.Bukkit;
+import org.bukkit.Material;
+import org.bukkit.NamespacedKey;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.ClickType;
+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.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+public final class BrowseMenu {
+
+ 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 String ACTION_BACK = "back";
+ private static final String ACTION_PREVIOUS = "previous";
+ private static final String ACTION_NEXT = "next";
+ private static final String ACTION_BROWSE_ALL = "browse-all";
+ private static final String ACTION_HIDDEN = "hidden";
+ private static final String ACTION_TAGS = "tags";
+ private static final String ACTION_COLLECTIONS = "collections";
+ private static final String ACTION_SEARCH = "search";
+ private static final String ACTION_CREATE_HEAD = "create-head";
+ private static final String ACTION_CREATE_CATEGORY = "create-category";
+ private static final String ACTION_CUSTOM_HEADS = "custom-heads";
+ private static final String ACTION_CATEGORY = "category:";
+ private static final int[] CATEGORY_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 BrowseMenu() {
+ throw new UnsupportedOperationException("This class cannot be instantiated.");
+ }
+
+ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ open(plugin, player, 0);
+ }
+
+ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, int requestedPage) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+
+ if (!Permissions.has(player, Permissions.GUI_BROWSE_MENU)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ List categories = categories(plugin, player);
+ int pages = pageCount(categories.size());
+ int page = Math.max(0, Math.min(requestedPage, pages - 1));
+ BrowseHolder holder = new BrowseHolder(page);
+ Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(plugin.guiConfig().text("title.browse", "Browse"), plugin.adminModes().enabled(player)));
+ holder.inventory(inventory);
+
+ fillBorder(plugin, inventory);
+ renderTopActions(plugin, player, inventory);
+ renderCategories(plugin, player, inventory, categories, page);
+ renderControls(plugin, player, inventory, categories.size(), page, pages);
+ player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
+ }
+
+ 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() instanceof BrowseHolder 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;
+ }
+
+ plugin.sounds().playGuiAction(player, action.get());
+
+ handleAction(plugin, player, holder, action.get(), event.getClick());
+ return true;
+ }
+
+ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull BrowseHolder holder, @NotNull String action, @NotNull ClickType click) {
+ if (action.equals(ACTION_BACK)) {
+ plugin.guis().openMain(player);
+ return;
+ }
+
+ if (action.equals(ACTION_PREVIOUS)) {
+ open(plugin, player, holder.page() - 1);
+ return;
+ }
+
+ if (action.equals(ACTION_NEXT)) {
+ open(plugin, player, holder.page() + 1);
+ return;
+ }
+
+ if (action.equals(ACTION_BROWSE_ALL)) {
+ if (!Permissions.has(player, Permissions.GUI_BROWSE) || !Permissions.canViewAllCategories(player)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ plugin.guis().openSearch(player, browseRequest(), SearchMenuState.BackTarget.BROWSE);
+ return;
+ }
+
+ if (action.equals(ACTION_HIDDEN)) {
+ if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_HIDDEN_HEADS)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ HiddenHeadsMenu.open(plugin, player);
+ return;
+ }
+
+ if (action.equals(ACTION_TAGS)) {
+ if (!Permissions.has(player, Permissions.GUI_TAGS)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ TagsMenu.open(plugin, player);
+ return;
+ }
+
+ if (action.equals(ACTION_COLLECTIONS)) {
+ if (!Permissions.has(player, Permissions.GUI_COLLECTIONS)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ CollectionsMenu.open(plugin, player);
+ return;
+ }
+
+ if (action.equals(ACTION_SEARCH)) {
+ if (!Permissions.has(player, Permissions.GUI_SEARCH) || !Permissions.has(player, Permissions.SEARCH)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ if (click == ClickType.RIGHT || click == ClickType.SHIFT_RIGHT) {
+ plugin.guis().openAdvancedSearch(player, browseRequest(), SearchMenuState.BackTarget.BROWSE);
+ return;
+ }
+
+ player.closeInventory();
+ player.getScheduler().run(plugin, task -> plugin.prompts().request(player, Component.text("Enter a search query.", NamedTextColor.GOLD), query -> {
+ plugin.guis().openSearch(player, new SearchRequest(query, Set.of(), Set.of(), Set.of(), Set.of(), HeadSort.RELEVANCE, SortDirection.DESCENDING, 1, 28, false), SearchMenuState.BackTarget.BROWSE);
+ }, () -> {
+ player.sendMessage(Component.text("Search cancelled.", NamedTextColor.GRAY));
+ open(plugin, player, holder.page());
+ }), () -> {});
+ return;
+ }
+
+ if (action.equals(ACTION_CREATE_HEAD)) {
+ if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CREATE_HEAD)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ CreateHeadMenu.openNew(plugin, player);
+ return;
+ }
+
+ if (action.equals(ACTION_CUSTOM_HEADS)) {
+ if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CUSTOM_HEADS)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ plugin.guis().openCustomHeads(player);
+ return;
+ }
+
+ if (action.equals(ACTION_CREATE_CATEGORY)) {
+ if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ CreateCategoryMenu.open(plugin, player);
+ return;
+ }
+
+ if (!action.startsWith(ACTION_CATEGORY)) {
+ return;
+ }
+
+ String category = action.substring(ACTION_CATEGORY.length());
+ Optional customCategory = plugin.customCategories().find(category);
+ if (customCategory.isPresent() && (click == ClickType.RIGHT || click == ClickType.SHIFT_RIGHT) && plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN)) {
+ CreateCategoryMenu.openExisting(plugin, player, customCategory.get().id());
+ return;
+ }
+
+ if (!Permissions.has(player, Permissions.GUI_CATEGORY_OPEN) || !Permissions.canViewCategory(player, category)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ if (customCategory.isPresent()) {
+ CustomCategoryViewMenu.open(plugin, player, customCategory.get().id(), 0);
+ return;
+ }
+
+ plugin.guis().openSearch(player, categoryRequest(category), SearchMenuState.BackTarget.BROWSE);
+ }
+
+ private static void renderTopActions(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory) {
+ if (plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_HIDDEN_HEADS)) {
+ setAction(plugin, inventory, plugin.guiConfig().slot("browse.hidden-heads", 35), "hidden-heads", ACTION_HIDDEN);
+ }
+
+ if (Permissions.has(player, Permissions.GUI_TAGS)) {
+ setAction(plugin, inventory, plugin.guiConfig().slot("browse.tags", 2), "tags", ACTION_TAGS);
+ }
+
+ if (Permissions.has(player, Permissions.GUI_BROWSE) && Permissions.canViewAllCategories(player)) {
+ setAction(plugin, inventory, plugin.guiConfig().slot("browse.all", 4), "browse-all", ACTION_BROWSE_ALL);
+ }
+
+ if (Permissions.has(player, Permissions.GUI_COLLECTIONS)) {
+ setAction(plugin, inventory, plugin.guiConfig().slot("browse.collections", 6), "collections", ACTION_COLLECTIONS);
+ }
+
+ if (plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN)) {
+ setAction(plugin, inventory, plugin.guiConfig().slot("browse.create-category", 18), "create-category", ACTION_CREATE_CATEGORY);
+ }
+
+ if (plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CUSTOM_HEADS)) {
+ setAction(plugin, inventory, plugin.guiConfig().slot("browse.custom-heads", 26), "custom-heads", ACTION_CUSTOM_HEADS);
+ }
+
+ if (plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CREATE_HEAD)) {
+ setAction(plugin, inventory, plugin.guiConfig().slot("browse.create-head", 27), "create-head", ACTION_CREATE_HEAD);
+ }
+
+ if (Permissions.has(player, Permissions.GUI_SEARCH)) {
+ setAction(plugin, inventory, plugin.guiConfig().slot("browse.search", 53), "search", ACTION_SEARCH);
+ }
+ }
+
+ private static void setAction(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, int slot, @NotNull String iconKey, @NotNull String action) {
+ ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey));
+ stamp(plugin, item, action);
+ inventory.setItem(slot, item);
+ }
+
+ private static void renderCategories(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull List categories, int page) {
+ boolean adminMode = plugin.adminModes().enabled(player);
+ Map counts = plugin.headRegistry().categoryCounts(adminMode);
+ Map iconHeads = categoryIconHeads(plugin, categories, adminMode);
+ Map customCategories = customCategories(plugin);
+ int fromIndex = page * CATEGORY_SLOTS.length;
+ int toIndex = Math.min(categories.size(), fromIndex + CATEGORY_SLOTS.length);
+ int slotIndex = 0;
+
+ for (int index = fromIndex; index < toIndex; index++) {
+ HeadCategory category = categories.get(index);
+ CustomCategory customCategory = customCategories.get(category.id());
+ int amount = customCategory == null ? counts.getOrDefault(category.id(), 0) : customCategoryCount(plugin, customCategory, adminMode);
+ ItemStack item = customCategory == null ? categoryItem(plugin, category, iconHeads.get(category.id()), amount, adminMode) : customCategoryItem(plugin, category, customCategory, amount, adminMode);
+ stamp(plugin, item, ACTION_CATEGORY + category.id());
+ inventory.setItem(CATEGORY_SLOTS[slotIndex], item);
+ slotIndex++;
+ }
+ }
+
+
+ private static int customCategoryCount(@NotNull HeadDBPlugin plugin, @NotNull CustomCategory category, boolean adminMode) {
+ int count = 0;
+ for (HeadId id : category.headIds()) {
+ boolean visible = id.isCustom() && adminMode ? plugin.headRegistry().customHeads().findStored(id).isPresent() : plugin.headRegistry().find(id).isPresent();
+ if (visible) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, int categoryCount, 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);
+ }
+
+ ItemStack info = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("info"), GuiItems.name("Info", NamedTextColor.GOLD), List.of(
+ GuiItems.idDetail("Categories", categoryCount),
+ GuiItems.idDetail("Page", (page + 1) + " / " + Math.max(1, pages))
+ ));
+ inventory.setItem(SLOT_INFO, info);
+
+ 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 @NotNull ItemStack customCategoryItem(@NotNull HeadDBPlugin plugin, @NotNull HeadCategory category, @NotNull CustomCategory customCategory, int amount, boolean adminMode) {
+ ItemStack item;
+ if (customCategory.headIcon()) {
+ item = plugin.headRegistry().find(new HeadId(customCategory.headIconId())).map(plugin.itemFactory()::create).orElseGet(() -> GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("category")));
+ } else {
+ item = GuiItems.item(GuiMaterials.itemOr(customCategory.material(), Material.CHEST), Component.empty(), List.of());
+ }
+
+ item.editMeta(meta -> {
+ List lore = new ArrayList<>();
+ if (!customCategory.description().isBlank()) {
+ lore.add(GuiItems.lore(customCategory.description(), NamedTextColor.GRAY));
+ lore.add(Component.empty());
+ }
+ if (adminMode) {
+ lore.add(GuiItems.idDetail("Heads", amount));
+ lore.add(GuiItems.idDetail("ID", customCategory.id()));
+ lore.add(GuiItems.idDetail("Permission", Permissions.category(customCategory.id())));
+ if (customCategory.draft()) {
+ lore.add(GuiItems.idDetail("State", "DRAFT"));
+ }
+ lore.add(Component.empty());
+ }
+ lore.add(GuiItems.lore("Click to browse this category.", NamedTextColor.GREEN));
+ if (adminMode) {
+ lore.add(GuiItems.lore("Right-click to edit this custom category.", NamedTextColor.YELLOW));
+ }
+
+ meta.displayName(GuiItems.name(customCategory.draft() ? "DRAFT - " + customCategory.name() : customCategory.name(), customCategory.draft() ? NamedTextColor.YELLOW : NamedTextColor.GOLD));
+ meta.lore(lore);
+ meta.getPersistentDataContainer().remove(HeadItemIds.key(plugin));
+ });
+ return item;
+ }
+
+ private static @NotNull ItemStack categoryItem(@NotNull HeadDBPlugin plugin, @NotNull HeadCategory category, Head iconHead, int amount, boolean adminMode) {
+ String iconKey = categoryIconKey(category.id());
+ boolean configured = plugin.guiConfig().hasIcon(iconKey);
+ GuiIconConfig icon = plugin.guiConfig().iconOrDefault(iconKey, "category");
+ ItemStack item = categoryIcon(plugin, iconHead, configured, icon);
+ item.editMeta(meta -> {
+ List lore = new ArrayList<>();
+
+ if (configured) {
+ lore.addAll(GuiItems.miniLore(replaceAll(icon.lore(), category, amount)));
+ } else if (category.description() != null && !category.description().isBlank()) {
+ lore.add(Component.text(category.description(), NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false));
+ }
+
+ if (!lore.isEmpty()) {
+ lore.add(Component.empty());
+ }
+
+ if (adminMode) {
+ lore.add(GuiItems.idDetail("Heads", amount));
+ lore.add(GuiItems.idDetail("ID", category.id()));
+ lore.add(Component.empty());
+ }
+
+ lore.add(GuiItems.lore("Click to browse this category.", NamedTextColor.GREEN));
+ Component name = configured ? GuiItems.mini(replace(icon.name(), category, amount)) : GuiItems.name(category.name(), NamedTextColor.GOLD);
+ meta.displayName(name);
+ meta.lore(lore);
+ meta.getPersistentDataContainer().remove(HeadItemIds.key(plugin));
+ meta.getPersistentDataContainer().set(GuiButtonEditorMenu.iconKey(plugin), PersistentDataType.STRING, iconKey);
+ });
+ return item;
+ }
+
+ private static @NotNull ItemStack categoryIcon(@NotNull HeadDBPlugin plugin, Head iconHead, boolean configured, @NotNull GuiIconConfig icon) {
+ if (configured) {
+ return GuiHeadIcons.icon(plugin, icon);
+ }
+
+ if (iconHead == null) {
+ return GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("category"));
+ }
+
+ return plugin.itemFactory().create(iconHead);
+ }
+
+ private static @NotNull String categoryIconKey(@NotNull String categoryId) {
+ return "category." + categoryId.trim().toLowerCase(java.util.Locale.ROOT).replaceAll("[^a-z0-9_.-]", "_");
+ }
+
+ private static @NotNull String replace(@NotNull String value, @NotNull HeadCategory category, int amount) {
+ return value.replace("%name%", category.name()).replace("%id%", category.id()).replace("%count%", String.valueOf(amount));
+ }
+
+ private static @NotNull List replaceAll(@NotNull List values, @NotNull HeadCategory category, int amount) {
+ List result = new ArrayList<>();
+ for (String value : values) {
+ result.add(replace(value, category, amount));
+ }
+ return List.copyOf(result);
+ }
+
+ private static @NotNull Map categoryIconHeads(@NotNull HeadDBPlugin plugin, @NotNull List categories, boolean includeHidden) {
+ Map icons = new LinkedHashMap<>();
+ Set missing = new HashSet<>();
+ for (HeadCategory category : categories) {
+ missing.add(category.id());
+ }
+
+ if (missing.isEmpty()) {
+ return icons;
+ }
+
+ for (Head head : plugin.headRegistry().heads(includeHidden)) {
+ String category = head.category();
+ if (!missing.contains(category)) {
+ continue;
+ }
+
+ icons.put(category, head);
+ missing.remove(category);
+ if (missing.isEmpty()) {
+ return icons;
+ }
+ }
+
+ return icons;
+ }
+
+ private static @NotNull List categories(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ boolean adminMode = plugin.adminModes().enabled(player);
+ Map customById = customCategories(plugin);
+ List remoteCategories = plugin.headRegistry().categories().stream()
+ .filter(category -> !customById.containsKey(category.id()))
+ .filter(category -> Permissions.canViewCategory(player, category.id()))
+ .sorted(Comparator.comparing(HeadCategory::name, String.CASE_INSENSITIVE_ORDER))
+ .toList();
+ List customCategories = plugin.customCategories().listVisible(adminMode).stream()
+ .filter(category -> Permissions.canViewCategory(player, category.id()))
+ .sorted(Comparator.comparing(CustomCategory::name, String.CASE_INSENSITIVE_ORDER))
+ .map(CustomCategory::toHeadCategory)
+ .toList();
+
+ List result = new ArrayList<>();
+ result.addAll(remoteCategories);
+ result.addAll(customCategories);
+ return List.copyOf(result);
+ }
+
+ private static @NotNull Map customCategories(@NotNull HeadDBPlugin plugin) {
+ Map categories = new LinkedHashMap<>();
+ for (CustomCategory category : plugin.customCategories().list()) {
+ categories.put(category.id(), category);
+ }
+ return Map.copyOf(categories);
+ }
+
+ private static int pageCount(int entries) {
+ if (entries <= 0) {
+ return 1;
+ }
+ return (int) Math.ceil((double) entries / (double) CATEGORY_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 = new ItemStack(material);
+ filler.editMeta(meta -> {
+ meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name()));
+ meta.lore(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 (GuiMaterials.isAir(item.getType())) {
+ return Optional.empty();
+ }
+ 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, "browse_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 static @NotNull SearchRequest browseRequest() {
+ return new SearchRequest("", Set.of(), Set.of(), Set.of(), Set.of(), HeadSort.ID, SortDirection.ASCENDING, 1, 28, false);
+ }
+
+ private static @NotNull SearchRequest categoryRequest(@NotNull String category) {
+ return new SearchRequest("", Set.of(), Set.of(category), Set.of(), Set.of(), HeadSort.ID, SortDirection.ASCENDING, 1, 28, true);
+ }
+
+ private static final class BrowseHolder implements InventoryHolder {
+
+ private final int page;
+ private Inventory inventory;
+
+ private BrowseHolder(int page) {
+ this.page = page;
+ }
+
+ private int page() {
+ return page;
+ }
+
+ private void inventory(@NotNull Inventory inventory) {
+ this.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/CategoryHeadPickerMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryHeadPickerMenu.java
index 83bdc54..a70fad9 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryHeadPickerMenu.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryHeadPickerMenu.java
@@ -7,6 +7,7 @@
import io.github.silentdevelopment.headdb.paper.command.search.SearchParser;
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.GuiLabels;
import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials;
import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles;
import io.github.silentdevelopment.headdb.paper.item.HeadItemIds;
@@ -38,7 +39,7 @@ public final class CategoryHeadPickerMenu {
private static final int SLOT_BACK = 45;
private static final int SLOT_PREVIOUS = 48;
private static final int SLOT_TYPE = 49;
- private static final int SLOT_NEXT = 53;
+ private static final int SLOT_NEXT = 50;
private static final String ACTION_BACK = "back";
private static final String ACTION_PREVIOUS = "previous";
private static final String ACTION_NEXT = "next";
@@ -77,6 +78,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @N
renderHeads(plugin, inventory, heads, page);
renderControls(plugin, inventory, page, pages);
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) {
@@ -103,6 +105,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
return true;
}
+ plugin.sounds().playGuiAction(player, action.get());
+
handleAction(plugin, player, holder, action.get(), item);
return true;
}
@@ -143,7 +147,7 @@ private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Invent
private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull String action, @NotNull ItemStack item) {
if (action.equals(ACTION_BACK)) {
plugin.customCategories().find(holder.categoryId()).ifPresentOrElse(
- category -> MoreCategoriesMenu.openEdit(plugin, player, category.id(), category.name(), category.material()),
+ category -> CreateCategoryMenu.openExisting(plugin, player, category.id()),
() -> MoreCategoriesMenu.open(plugin, player)
);
return;
@@ -188,7 +192,8 @@ private static void promptId(@NotNull HeadDBPlugin plugin, @NotNull Player playe
return;
}
- if (plugin.headRegistry().find(id).isEmpty()) {
+ boolean exists = id.isCustom() ? plugin.headRegistry().customHeads().findStored(id).isPresent() : plugin.headRegistry().find(id).isPresent();
+ if (!exists) {
player.sendMessage(Component.text("Unknown head: ", NamedTextColor.RED).append(Component.text(id.display(), NamedTextColor.GOLD)));
open(plugin, player, categoryId, 0);
return;
@@ -200,12 +205,20 @@ private static void promptId(@NotNull HeadDBPlugin plugin, @NotNull Player playe
private static void add(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String categoryId, @NotNull HeadId id) {
plugin.customCategories().addHead(categoryId, id);
- player.sendMessage(Component.text("Head added: ", NamedTextColor.GRAY).append(Component.text(id.display(), NamedTextColor.GOLD)));
+ player.sendMessage(Component.text("Head added: ", NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id), NamedTextColor.GOLD)));
CategoryMembersMenu.open(plugin, player, categoryId, 0);
}
private static @NotNull List heads(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
- List heads = new ArrayList<>(plugin.headRegistry().heads(plugin.adminModes().enabled(player)));
+ boolean adminMode = plugin.adminModes().enabled(player);
+ List heads = new ArrayList<>(plugin.headRegistry().heads(adminMode));
+ if (adminMode) {
+ for (io.github.silentdevelopment.headdb.paper.local.custom.StoredCustomHead head : plugin.headRegistry().customHeads().listStored()) {
+ if (head.draft()) {
+ heads.add(head.toHead());
+ }
+ }
+ }
for (PlayerHeadEntry entry : plugin.headRegistry().playerHeads().knownPlayers()) {
heads.add(playerHead(entry));
}
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryMembersMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryMembersMenu.java
index 8dfc431..998b7c6 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryMembersMenu.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CategoryMembersMenu.java
@@ -5,6 +5,7 @@
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.GuiLabels;
import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials;
import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles;
import net.kyori.adventure.text.Component;
@@ -31,7 +32,7 @@ public final class CategoryMembersMenu {
private static final int SLOT_BACK = 45;
private static final int SLOT_PREVIOUS = 48;
private static final int SLOT_ADD = 49;
- private static final int SLOT_NEXT = 53;
+ private static final int SLOT_NEXT = 50;
private static final String ACTION_BACK = "back";
private static final String ACTION_PREVIOUS = "previous";
private static final String ACTION_NEXT = "next";
@@ -71,6 +72,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @N
renderMembers(plugin, inventory, ids, page);
renderControls(plugin, inventory, page, pages);
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) {
@@ -97,6 +99,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
return true;
}
+ plugin.sounds().playGuiAction(player, action.get());
+
handleAction(plugin, player, holder, action.get());
return true;
}
@@ -116,7 +120,7 @@ private static void renderMembers(@NotNull HeadDBPlugin plugin, @NotNull Invento
}
private static @NotNull ItemStack memberItem(@NotNull HeadDBPlugin plugin, @NotNull HeadId id) {
- Optional head = plugin.headRegistry().find(id);
+ Optional head = id.isCustom() ? plugin.headRegistry().customHeads().findStored(id).map(stored -> stored.toHead()) : plugin.headRegistry().find(id);
if (head.isPresent()) {
ItemStack item = plugin.itemFactory().create(head.get());
item.editMeta(meta -> {
@@ -146,7 +150,7 @@ private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Invent
private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull String action) {
if (action.equals(ACTION_BACK)) {
plugin.customCategories().find(holder.categoryId()).ifPresentOrElse(
- category -> MoreCategoriesMenu.openEdit(plugin, player, category.id(), category.name(), category.material()),
+ category -> CreateCategoryMenu.openExisting(plugin, player, category.id()),
() -> MoreCategoriesMenu.open(plugin, player)
);
return;
@@ -180,7 +184,7 @@ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player p
}
plugin.customCategories().removeHead(holder.categoryId(), id);
- player.sendMessage(Component.text("Head removed: ", NamedTextColor.GRAY).append(Component.text(id.display(), NamedTextColor.GOLD)));
+ player.sendMessage(Component.text("Head removed: ", NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id), NamedTextColor.GOLD)));
open(plugin, player, holder.categoryId(), holder.page());
}
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CollectionsMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CollectionsMenu.java
new file mode 100644
index 0000000..d940896
--- /dev/null
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CollectionsMenu.java
@@ -0,0 +1,388 @@
+package io.github.silentdevelopment.headdb.paper.gui.category;
+
+import io.github.silentdevelopment.headdb.model.Head;
+import io.github.silentdevelopment.headdb.model.HeadCollection;
+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.local.taxonomy.CustomTaxonomyEntry;
+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.paper.gui.search.SearchMenuState;
+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.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+public final class CollectionsMenu {
+
+ 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_CREATE = 27;
+ private static final int SLOT_FILTER = 52;
+ private static final int SLOT_SEARCH = 53;
+ 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_CREATE = "create";
+ private static final String ACTION_FILTER = "filter";
+ private static final String ACTION_SEARCH = "search";
+ private static final String ACTION_ENTRY = "collection:";
+ 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 CollectionsMenu() {
+ throw new UnsupportedOperationException("This class cannot be instantiated.");
+ }
+
+ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ open(plugin, player, 0, false, "");
+ }
+
+ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, int requestedPage, boolean customOnly, @NotNull String query) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(query, "query");
+
+ if (!Permissions.has(player, Permissions.GUI_COLLECTIONS)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ boolean adminMode = plugin.adminModes().enabled(player);
+ Set customIds = customIds(plugin);
+ Map counts = counts(visibleHeads(plugin, player));
+ List entries = plugin.headRegistry().collections().stream()
+ .filter(entry -> adminMode || counts.containsKey(entry.id()))
+ .filter(entry -> !customOnly || customIds.contains(entry.id()))
+ .filter(entry -> matches(entry.id(), entry.name(), query))
+ .sorted(Comparator.comparing(HeadCollection::name, String.CASE_INSENSITIVE_ORDER))
+ .toList();
+ int pages = pageCount(entries.size());
+ int page = Math.max(0, Math.min(requestedPage, pages - 1));
+ Holder holder = new Holder(page, customOnly, query.trim());
+ Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(title(plugin, page, pages, customOnly, query), adminMode));
+ holder.inventory(inventory);
+
+ fillBorder(plugin, inventory);
+ renderEntries(plugin, player, inventory, entries, counts, customIds, page);
+ renderControls(plugin, player, inventory, entries.size(), page, pages, customOnly, query);
+ player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
+ }
+
+ 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() 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;
+ }
+
+ plugin.sounds().playGuiAction(player, action.get());
+
+ handleAction(plugin, player, holder, action.get());
+ return true;
+ }
+
+ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull String action) {
+ if (action.equals(ACTION_BACK)) {
+ plugin.guis().openBrowse(player);
+ return;
+ }
+
+ if (action.equals(ACTION_PREVIOUS)) {
+ open(plugin, player, holder.page() - 1, holder.customOnly(), holder.query());
+ return;
+ }
+
+ if (action.equals(ACTION_NEXT)) {
+ open(plugin, player, holder.page() + 1, holder.customOnly(), holder.query());
+ return;
+ }
+
+ if (action.equals(ACTION_CREATE)) {
+ if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CREATE_COLLECTION)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ CreateTaxonomyMenu.openCollection(plugin, player);
+ return;
+ }
+
+ if (action.equals(ACTION_FILTER)) {
+ if (!plugin.adminModes().enabled(player)) {
+ return;
+ }
+
+ open(plugin, player, 0, !holder.customOnly(), holder.query());
+ return;
+ }
+
+ if (action.equals(ACTION_SEARCH)) {
+ player.closeInventory();
+ player.getScheduler().run(plugin, task -> plugin.prompts().request(player, Component.text("Enter collection search text.", NamedTextColor.GOLD), value -> open(plugin, player, 0, holder.customOnly(), value), () -> open(plugin, player, holder.page(), holder.customOnly(), holder.query())), () -> {});
+ return;
+ }
+
+ if (!action.startsWith(ACTION_ENTRY)) {
+ return;
+ }
+
+ String id = action.substring(ACTION_ENTRY.length());
+ plugin.guis().openSearch(player, new SearchRequest("", Set.of(), Set.of(), Set.of(), Set.of(id), HeadSort.NAME, SortDirection.ASCENDING, 1, 28, false), SearchMenuState.BackTarget.COLLECTIONS);
+ }
+
+ private static void renderEntries(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull List entries, @NotNull Map counts, @NotNull Set customIds, int page) {
+ 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++) {
+ HeadCollection entry = entries.get(index);
+ ItemStack item = entryItem(plugin, player, entry, counts.getOrDefault(entry.id(), 0), customIds.contains(entry.id()));
+ stamp(plugin, item, ACTION_ENTRY + entry.id());
+ inventory.setItem(ENTRY_SLOTS[slotIndex], item);
+ slotIndex++;
+ }
+ }
+
+ private static @NotNull ItemStack entryItem(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadCollection entry, int heads, boolean custom) {
+ ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("collections"));
+ item.editMeta(meta -> {
+ java.util.List lore = new java.util.ArrayList<>();
+ if (entry.description() != null && !entry.description().isBlank()) {
+ lore.add(GuiItems.lore(entry.description(), NamedTextColor.GRAY));
+ lore.add(Component.empty());
+ }
+ if (plugin.adminModes().enabled(player)) {
+ lore.add(GuiItems.idDetail("Heads", heads));
+ lore.add(GuiItems.idDetail("ID", entry.id()));
+ lore.add(GuiItems.idDetail("Type", custom ? "Custom" : "Remote"));
+ lore.add(Component.empty());
+ }
+ lore.add(GuiItems.lore("Click to browse this collection.", NamedTextColor.GREEN));
+ meta.displayName(GuiItems.name(entry.name(), custom ? NamedTextColor.YELLOW : NamedTextColor.GOLD));
+ meta.lore(lore);
+ });
+ return item;
+ }
+
+ private static @NotNull List visibleHeads(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ boolean includeHidden = plugin.adminModes().enabled(player);
+ return plugin.headRegistry().heads(includeHidden).stream().filter(head -> Permissions.canViewCategory(player, head.category())).toList();
+ }
+
+ private static @NotNull Map counts(@NotNull List heads) {
+ Map counts = new HashMap<>();
+ for (Head head : heads) {
+ for (String value : head.collections()) {
+ counts.merge(value, 1, Integer::sum);
+ }
+ }
+ return Map.copyOf(counts);
+ }
+
+ private static @NotNull Set customIds(@NotNull HeadDBPlugin plugin) {
+ Set ids = new HashSet<>();
+ for (CustomTaxonomyEntry entry : plugin.customCollections().list()) {
+ ids.add(entry.id());
+ }
+ return Set.copyOf(ids);
+ }
+
+ private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, int entries, int page, int pages, boolean customOnly, @NotNull String query) {
+ 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("Collections", entries),
+ GuiItems.idDetail("Page", (page + 1) + " / " + Math.max(1, pages)),
+ GuiItems.idDetail("Filter", customOnly ? "Custom" : "All"),
+ GuiItems.idDetail("Search", query.isBlank() ? "None" : query)
+ )));
+
+ if (page + 1 < pages) {
+ ItemStack next = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("next"));
+ stamp(plugin, next, ACTION_NEXT);
+ inventory.setItem(SLOT_NEXT, next);
+ }
+
+ if (plugin.adminModes().enabled(player)) {
+ ItemStack filter = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(customOnly ? "taxonomy-filter-custom" : "taxonomy-filter-all"));
+ stamp(plugin, filter, ACTION_FILTER);
+ inventory.setItem(plugin.guiConfig().slot("collections.filter", SLOT_FILTER), filter);
+ }
+
+ ItemStack search = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("taxonomy-search"));
+ stamp(plugin, search, ACTION_SEARCH);
+ inventory.setItem(plugin.guiConfig().slot("collections.search", SLOT_SEARCH), search);
+
+ if (plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CREATE_COLLECTION)) {
+ ItemStack create = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("create-collection"));
+ stamp(plugin, create, ACTION_CREATE);
+ inventory.setItem(plugin.guiConfig().slot("collections.create", SLOT_CREATE), create);
+ }
+ }
+
+ private static boolean matches(@NotNull String id, @NotNull String name, @NotNull String query) {
+ String trimmed = query.trim().toLowerCase(Locale.ROOT);
+ if (trimmed.isBlank()) {
+ return true;
+ }
+
+ String haystack = id.toLowerCase(Locale.ROOT) + " " + name.toLowerCase(Locale.ROOT);
+ for (String part : trimmed.split("\\s+")) {
+ if (part.isBlank()) {
+ continue;
+ }
+ if (!haystack.contains(part)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ 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 = new ItemStack(material);
+ filler.editMeta(meta -> {
+ meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name()));
+ meta.lore(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 @NotNull String title(@NotNull HeadDBPlugin plugin, int page, int pages, boolean customOnly, @NotNull String query) {
+ String key = customOnly ? "title.collections-custom-page" : "title.collections-page";
+ String fallback = customOnly ? "Custom Collections %page%/%pages%" : "Collections %page%/%pages%";
+ return plugin.guiConfig().text(key, fallback)
+ .replace("%page%", String.valueOf(page + 1))
+ .replace("%pages%", String.valueOf(Math.max(1, pages)))
+ .replace("%query%", query.trim());
+ }
+
+ private static int pageCount(int entries) {
+ if (entries <= 0) {
+ return 1;
+ }
+ return (int) Math.ceil((double) entries / (double) ENTRY_SLOTS.length);
+ }
+
+ 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, "collections_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 static final class Holder implements InventoryHolder {
+ private final int page;
+ private final boolean customOnly;
+ private final String query;
+ private Inventory inventory;
+
+ private Holder(int page, boolean customOnly, @NotNull String query) {
+ this.page = page;
+ this.customOnly = customOnly;
+ this.query = Objects.requireNonNull(query, "query");
+ }
+
+ private int page() { return page; }
+ private boolean customOnly() { return customOnly; }
+ private @NotNull String query() { return query; }
+
+ private void inventory(@NotNull Inventory inventory) {
+ this.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/CreateCategoryMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateCategoryMenu.java
new file mode 100644
index 0000000..e190c64
--- /dev/null
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateCategoryMenu.java
@@ -0,0 +1,393 @@
+package io.github.silentdevelopment.headdb.paper.gui.category;
+
+import io.github.silentdevelopment.headdb.model.HeadId;
+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.message.MessageKey;
+import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
+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 org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.Map;
+
+public final class CreateCategoryMenu {
+
+ private static final int SIZE = 54;
+ private static final int ROWS = 6;
+ private static final int SLOT_PREVIEW = 4;
+ private static final int SLOT_NAME = 20;
+ private static final int SLOT_DESCRIPTION = 22;
+ private static final int SLOT_MATERIAL = 24;
+ private static final int SLOT_HEAD_ICON = 29;
+ private static final int SLOT_PERMISSION = 31;
+ private static final int SLOT_HEADS = 33;
+ private static final int SLOT_SAVE_DRAFT = 38;
+ private static final int SLOT_PUBLISH = 40;
+ private static final int SLOT_DELETE = 42;
+ private static final int SLOT_PRICE = 44;
+ private static final int SLOT_BACK = 45;
+ private static final Map ACTIVE_DRAFTS = new ConcurrentHashMap<>();
+ private static final String ACTION_BACK = "back";
+ private static final String ACTION_NAME = "name";
+ private static final String ACTION_DESCRIPTION = "description";
+ private static final String ACTION_MATERIAL = "material";
+ private static final String ACTION_HEAD_ICON = "head-icon";
+ private static final String ACTION_PERMISSION = "permission";
+ private static final String ACTION_HEADS = "heads";
+ private static final String ACTION_SAVE_DRAFT = "save-draft";
+ private static final String ACTION_PUBLISH = "publish";
+ private static final String ACTION_DELETE = "delete";
+ private static final String ACTION_PRICE = "price";
+
+ private CreateCategoryMenu() {
+ throw new UnsupportedOperationException("This class cannot be instantiated.");
+ }
+
+ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ if (!canUse(plugin, player)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ String id = createDraft(plugin, player);
+ ACTIVE_DRAFTS.put(player.getUniqueId(), id);
+ openExisting(plugin, player, id);
+ }
+
+ public static void openExisting(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String id) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(id, "id");
+
+ if (!canUse(plugin, player)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ Optional existing = plugin.customCategories().find(id);
+ if (existing.isEmpty()) {
+ ACTIVE_DRAFTS.remove(player.getUniqueId());
+ plugin.guis().openBrowse(player);
+ return;
+ }
+
+ ACTIVE_DRAFTS.put(player.getUniqueId(), existing.get().id());
+ Holder holder = new Holder(existing.get().id());
+ Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(existing.get().draft() ? "Create Category" : "Edit Category", true));
+ holder.inventory(inventory);
+
+ fillBorder(plugin, inventory);
+ render(plugin, inventory, existing.get());
+ player.openInventory(inventory);
+ plugin.sounds().play(player, SoundKey.MENU_OPEN);
+ }
+
+ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) {
+ if (!(event.getView().getTopInventory().getHolder() 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);
+ action.ifPresent(value -> {
+ if (!value.equals(ACTION_SAVE_DRAFT) && !value.equals(ACTION_PUBLISH)) {
+ plugin.sounds().playGuiAction(player, value);
+ }
+ handleAction(plugin, player, holder, value);
+ });
+ return true;
+ }
+
+ public static void handleClose(@NotNull HeadDBPlugin plugin, @NotNull Player player, @Nullable InventoryHolder holder) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+
+ if (!(holder instanceof Holder createHolder)) {
+ return;
+ }
+
+ plugin.customCategories().find(createHolder.id()).ifPresent(plugin.customCategories()::save);
+ }
+
+ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull String action) {
+ Optional existing = plugin.customCategories().find(holder.id());
+ if (existing.isEmpty()) {
+ plugin.guis().openBrowse(player);
+ return;
+ }
+
+ CustomCategory category = existing.get();
+ if (action.equals(ACTION_BACK)) {
+ plugin.guis().openBrowse(player);
+ return;
+ }
+
+ if (action.equals(ACTION_NAME)) {
+ prompt(plugin, player, category, "Enter the category display name.", value -> category.withName(value));
+ return;
+ }
+
+ if (action.equals(ACTION_DESCRIPTION)) {
+ prompt(plugin, player, category, "Enter the category description.", value -> category.withDescription(value));
+ return;
+ }
+
+ if (action.equals(ACTION_MATERIAL)) {
+ prompt(plugin, player, category, "Enter a Bukkit material name for the icon.", value -> category.withMaterial(value));
+ return;
+ }
+
+ if (action.equals(ACTION_HEAD_ICON)) {
+ prompt(plugin, player, category, "Enter a head id for the icon, or none to clear.", value -> headIcon(plugin, player, category, value));
+ return;
+ }
+
+ if (action.equals(ACTION_PERMISSION)) {
+ player.sendMessage(Component.text("Permission: ", NamedTextColor.GRAY).append(Component.text(Permissions.category(category.id()), NamedTextColor.GOLD)));
+ openExisting(plugin, player, category.id());
+ return;
+ }
+
+ if (action.equals(ACTION_HEADS)) {
+ CategoryMembersMenu.open(plugin, player, category.id(), 0);
+ return;
+ }
+
+ if (action.equals(ACTION_SAVE_DRAFT)) {
+ plugin.customCategories().save(category.withDraft(true));
+ player.sendMessage(Component.text("Category draft saved: ", NamedTextColor.GRAY).append(Component.text(categoryLabel(plugin, player, category), NamedTextColor.GOLD)));
+ plugin.sounds().play(player, SoundKey.SAVE_DRAFT);
+ openExisting(plugin, player, category.id());
+ return;
+ }
+
+ if (action.equals(ACTION_PUBLISH)) {
+ plugin.customCategories().save(category.withDraft(false));
+ ACTIVE_DRAFTS.remove(player.getUniqueId());
+ plugin.clearSearchCache();
+ player.sendMessage(Component.text("Category published: ", NamedTextColor.GRAY).append(Component.text(categoryLabel(plugin, player, category), NamedTextColor.GOLD)));
+ plugin.sounds().play(player, SoundKey.PUBLISH);
+ openExisting(plugin, player, category.id());
+ return;
+ }
+
+ if (action.equals(ACTION_DELETE)) {
+ DeleteCategoryConfirmMenu.open(plugin, player, category.id());
+ return;
+ }
+
+ if (action.equals(ACTION_PRICE)) {
+ promptPrice(plugin, player, category);
+ }
+ }
+
+ private static @NotNull CustomCategory headIcon(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CustomCategory category, @NotNull String value) {
+ String trimmed = value.trim();
+ if (trimmed.equalsIgnoreCase("none") || trimmed.equalsIgnoreCase("clear")) {
+ return category.withMaterial("CHEST");
+ }
+
+ HeadId id = trimmed.matches("[1-9][0-9]*") ? HeadId.remote(trimmed) : new HeadId(trimmed);
+ if (plugin.headRegistry().find(id).isEmpty()) {
+ player.sendMessage(Component.text("Unknown head: ", NamedTextColor.RED).append(Component.text(id.display(), NamedTextColor.GOLD)));
+ return category;
+ }
+
+ return category.withMaterial("HEAD:" + id);
+ }
+
+ private static void prompt(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CustomCategory category, @NotNull String message, @NotNull java.util.function.Function update) {
+ player.closeInventory();
+ plugin.prompts().request(player, Component.text(message, NamedTextColor.GOLD), value -> {
+ try {
+ CustomCategory updated = update.apply(value).withDraft(true);
+ plugin.customCategories().save(updated);
+ openExisting(plugin, player, updated.id());
+ } catch (IllegalArgumentException exception) {
+ player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED));
+ plugin.sounds().play(player, SoundKey.VALIDATION_ERROR);
+ openExisting(plugin, player, category.id());
+ }
+ }, () -> openExisting(plugin, player, category.id()));
+ }
+
+ private static void render(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, @NotNull CustomCategory category) {
+ inventory.setItem(SLOT_PREVIEW, preview(plugin, category));
+ inventory.setItem(plugin.guiConfig().slot("create-category.name", SLOT_NAME), actionItem(plugin, "category-name", ACTION_NAME, "Name", category.name()));
+ inventory.setItem(plugin.guiConfig().slot("create-category.description", SLOT_DESCRIPTION), actionItem(plugin, "category-description", ACTION_DESCRIPTION, "Description", category.description()));
+ inventory.setItem(plugin.guiConfig().slot("create-category.material", SLOT_MATERIAL), actionItem(plugin, "category-material", ACTION_MATERIAL, "Material", category.material()));
+ inventory.setItem(plugin.guiConfig().slot("create-category.head-icon", SLOT_HEAD_ICON), actionItem(plugin, "category-head-icon", ACTION_HEAD_ICON, "Head Icon", category.headIcon() ? category.headIconId() : "Not set"));
+ inventory.setItem(plugin.guiConfig().slot("create-category.permission", SLOT_PERMISSION), actionItem(plugin, "category-permission", ACTION_PERMISSION, "Permission", Permissions.category(category.id())));
+ inventory.setItem(plugin.guiConfig().slot("create-category.heads", SLOT_HEADS), actionItem(plugin, "category-view-heads", ACTION_HEADS, "Heads", category.headIds().size()));
+ inventory.setItem(plugin.guiConfig().slot("create-category.save-draft", SLOT_SAVE_DRAFT), actionOnly(plugin, "category-save-draft", ACTION_SAVE_DRAFT));
+ inventory.setItem(plugin.guiConfig().slot("create-category.publish", SLOT_PUBLISH), actionOnly(plugin, "category-publish", ACTION_PUBLISH));
+ inventory.setItem(plugin.guiConfig().slot("create-category.delete", SLOT_DELETE), actionOnly(plugin, "category-delete", ACTION_DELETE));
+ if (plugin.economy().enabled()) {
+ inventory.setItem(plugin.guiConfig().slot("create-category.price", SLOT_PRICE), actionOnly(plugin, "category-price", ACTION_PRICE));
+ }
+ inventory.setItem(plugin.guiConfig().slot("create-category.back", SLOT_BACK), actionOnly(plugin, "back", ACTION_BACK));
+ }
+
+ private static @NotNull String categoryLabel(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CustomCategory category) {
+ return plugin.adminModes().enabled(player) || Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN) ? category.name() + " (" + category.id() + ")" : category.name();
+ }
+
+ private static @NotNull ItemStack preview(@NotNull HeadDBPlugin plugin, @NotNull CustomCategory category) {
+ ItemStack item;
+ if (category.headIcon()) {
+ item = plugin.headRegistry().find(new HeadId(category.headIconId())).map(plugin.itemFactory()::create).orElseGet(() -> GuiItems.item(Material.CHEST, Component.empty(), List.of()));
+ } else {
+ item = GuiItems.item(GuiMaterials.itemOr(category.material(), Material.CHEST), Component.empty(), List.of());
+ }
+
+ item.editMeta(meta -> {
+ meta.displayName(GuiItems.name(category.draft() ? "DRAFT - " + category.name() : category.name(), category.draft() ? NamedTextColor.YELLOW : NamedTextColor.GOLD));
+ meta.lore(List.of(GuiItems.idDetail("ID", category.id()), GuiItems.idDetail("Heads", category.headIds().size()), GuiItems.idDetail("Permission", Permissions.category(category.id()))));
+ });
+ return item;
+ }
+
+ private static @NotNull ItemStack actionItem(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action, @NotNull String key, @NotNull Object value) {
+ ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey), GuiItems.mini(plugin.guiConfig().icon(iconKey).name()), List.of(GuiItems.idDetail(key, value), GuiItems.lore("Click to edit.", NamedTextColor.GREEN)));
+ stamp(plugin, item, action);
+ return item;
+ }
+
+ private static @NotNull ItemStack actionOnly(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action) {
+ ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey));
+ stamp(plugin, item, action);
+ return item;
+ }
+
+ private static @NotNull String createDraft(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ String id = plugin.customCategories().nextId();
+ CustomCategory category = new CustomCategory(id, "Draft Category " + id, "CHEST", "Local custom category.", true, Set.of());
+ plugin.customCategories().save(category);
+ return category.id();
+ }
+
+ private static void promptPrice(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CustomCategory category) {
+ if (!plugin.economy().enabled()) {
+ openExisting(plugin, player, category.id());
+ return;
+ }
+
+ player.closeInventory();
+ plugin.prompts().request(player, Component.text("Enter the new category price, or 0 to clear.", NamedTextColor.GOLD), value -> {
+ try {
+ double price = Double.parseDouble(value.trim());
+ plugin.economy().setCustomCategoryPrice(category.id(), price);
+ player.sendMessage(plugin.messages().priceUpdated(player, categoryLabel(plugin, player, category), plugin.economy().format(price)));
+ plugin.sounds().play(player, SoundKey.PRICE_SUCCESS);
+ } catch (NumberFormatException exception) {
+ player.sendMessage(plugin.messages().priceInvalid(player));
+ plugin.sounds().play(player, SoundKey.PRICE_FAILURE);
+ } catch (RuntimeException exception) {
+ player.sendMessage(Component.text("Failed to update category price: " + exception.getMessage(), NamedTextColor.RED));
+ plugin.sounds().play(player, SoundKey.PRICE_FAILURE);
+ }
+
+ openExisting(plugin, player, category.id());
+ }, () -> openExisting(plugin, player, category.id()));
+ }
+
+ private static boolean canUse(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ return plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN);
+ }
+
+ private static void fillBorder(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory) {
+ Material material = GuiMaterials.itemOr(plugin.guiConfig().filler().material(), Material.BLACK_STAINED_GLASS_PANE);
+ ItemStack filler = new ItemStack(material);
+ filler.editMeta(meta -> {
+ meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name()));
+ meta.lore(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, "create_category_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, SoundKey.NO_PERMISSION);
+ }
+
+ private static final class Holder implements InventoryHolder {
+ private final String id;
+ private Inventory inventory;
+
+ private Holder(@NotNull String id) {
+ this.id = Objects.requireNonNull(id, "id");
+ }
+
+ private @NotNull String id() {
+ return id;
+ }
+
+ private void inventory(@NotNull Inventory inventory) {
+ this.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/CreateHeadMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateHeadMenu.java
new file mode 100644
index 0000000..0ea0879
--- /dev/null
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateHeadMenu.java
@@ -0,0 +1,716 @@
+package io.github.silentdevelopment.headdb.paper.gui.category;
+
+import io.github.silentdevelopment.headdb.model.HeadCategory;
+import io.github.silentdevelopment.headdb.model.HeadCollection;
+import io.github.silentdevelopment.headdb.model.HeadId;
+import io.github.silentdevelopment.headdb.model.HeadTag;
+import io.github.silentdevelopment.headdb.model.HeadTexture;
+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.edit.HeadEditMenu;
+import io.github.silentdevelopment.headdb.paper.item.HeadItemIds;
+import io.github.silentdevelopment.headdb.paper.local.custom.StoredCustomHead;
+import io.github.silentdevelopment.headdb.paper.local.texture.TextureInputParser;
+import io.github.silentdevelopment.headdb.paper.message.MessageKey;
+import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
+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 org.jetbrains.annotations.Nullable;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Consumer;
+
+public final class CreateHeadMenu {
+
+ private static final int SIZE = 54;
+ private static final int ROWS = 6;
+ private static final int SLOT_PREVIEW = 4;
+ private static final int SLOT_USE_HELD = 12;
+ private static final int SLOT_START_FRESH = 14;
+ private static final int SLOT_ID = 20;
+ private static final int SLOT_NAME = 22;
+ private static final int SLOT_TEXTURE = 24;
+ private static final int SLOT_CATEGORY = 29;
+ private static final int SLOT_TAGS = 31;
+ private static final int SLOT_COLLECTIONS = 33;
+ private static final int SLOT_CREATE = 40;
+ private static final int SLOT_BACK = 45;
+ private static final int SLOT_SELECTOR_BACK = 45;
+ private static final int SLOT_SELECTOR_PREVIOUS = 48;
+ private static final int SLOT_SELECTOR_INFO = 49;
+ private static final int SLOT_SELECTOR_NEXT = 50;
+ private static final int SLOT_SELECTOR_CLEAR = 53;
+ private static final Map DRAFTS = new ConcurrentHashMap<>();
+ private static final String ACTION_BACK = "back";
+ private static final String ACTION_USE_HELD = "use-held";
+ private static final String ACTION_START_FRESH = "start-fresh";
+ private static final String ACTION_ID = "id";
+ private static final String ACTION_NAME = "name";
+ private static final String ACTION_TEXTURE = "texture";
+ private static final String ACTION_CATEGORY = "category";
+ private static final String ACTION_TAGS = "tags";
+ private static final String ACTION_COLLECTIONS = "collections";
+ private static final String ACTION_CREATE = "create";
+ private static final String ACTION_SELECTOR_BACK = "selector-back";
+ private static final String ACTION_SELECTOR_PREVIOUS = "selector-previous";
+ private static final String ACTION_SELECTOR_NEXT = "selector-next";
+ private static final String ACTION_SELECTOR_CLEAR = "selector-clear";
+ private static final String ACTION_SELECTOR_ENTRY = "selector-entry:";
+ private static final int[] SELECTOR_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 CreateHeadMenu() {
+ throw new UnsupportedOperationException("This class cannot be instantiated.");
+ }
+
+ public static void openNew(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ DRAFTS.put(player.getUniqueId(), Draft.empty());
+ open(plugin, player);
+ }
+
+ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+
+ if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CREATE_HEAD)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ Draft draft = DRAFTS.computeIfAbsent(player.getUniqueId(), ignored -> Draft.empty());
+ CreateHeadHolder holder = new CreateHeadHolder();
+ Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(plugin.guiConfig().text("title.create-head", "Create Head"), true));
+ holder.inventory(inventory);
+
+ fillBorder(plugin, inventory);
+ render(plugin, inventory, draft);
+ player.openInventory(inventory);
+ plugin.sounds().play(player, SoundKey.MENU_OPEN);
+ }
+
+ 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");
+
+ InventoryHolder holder = event.getView().getTopInventory().getHolder();
+ if (!(holder instanceof CreateHeadHolder) && !(holder instanceof CreateHeadSelectorHolder)) {
+ 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;
+ }
+
+ if (!action.get().equals(ACTION_CREATE)) {
+ plugin.sounds().playGuiAction(player, action.get());
+ }
+
+ if (holder instanceof CreateHeadSelectorHolder selectorHolder) {
+ handleSelectorAction(plugin, player, selectorHolder, action.get());
+ return true;
+ }
+
+ handleAction(plugin, player, action.get());
+ return true;
+ }
+
+ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String action) {
+ Draft draft = DRAFTS.computeIfAbsent(player.getUniqueId(), ignored -> Draft.empty());
+
+ if (action.equals(ACTION_BACK)) {
+ plugin.guis().openBrowse(player);
+ return;
+ }
+
+ if (action.equals(ACTION_START_FRESH)) {
+ DRAFTS.put(player.getUniqueId(), Draft.empty());
+ open(plugin, player);
+ return;
+ }
+
+ if (action.equals(ACTION_USE_HELD)) {
+ useHeld(plugin, player, draft);
+ return;
+ }
+
+ if (action.equals(ACTION_ID)) {
+ prompt(plugin, player, "Enter the custom head id.", value -> update(player, draft.withId(value)));
+ return;
+ }
+
+ if (action.equals(ACTION_NAME)) {
+ prompt(plugin, player, "Enter the custom head name.", value -> update(player, draft.withName(value)));
+ return;
+ }
+
+ if (action.equals(ACTION_TEXTURE)) {
+ prompt(plugin, player, "Enter a texture hash, URL, or base64 value.", value -> {
+ try {
+ HeadTexture texture = new TextureInputParser().parse(value);
+ update(player, draft.withTexture(texture.hash()));
+ } catch (IllegalArgumentException exception) {
+ player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED));
+ plugin.sounds().play(player, SoundKey.VALIDATION_ERROR);
+ }
+ });
+ return;
+ }
+
+ if (action.equals(ACTION_CATEGORY)) {
+ openSelector(plugin, player, SelectorMode.CATEGORY, 0);
+ return;
+ }
+
+ if (action.equals(ACTION_TAGS)) {
+ openSelector(plugin, player, SelectorMode.TAGS, 0);
+ return;
+ }
+
+ if (action.equals(ACTION_COLLECTIONS)) {
+ openSelector(plugin, player, SelectorMode.COLLECTIONS, 0);
+ return;
+ }
+
+ if (action.equals(ACTION_CREATE)) {
+ saveDraft(plugin, player, draft, true);
+ }
+ }
+
+
+ private static void openSelector(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull SelectorMode mode, int requestedPage) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(mode, "mode");
+
+ if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CREATE_HEAD)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ Draft draft = DRAFTS.computeIfAbsent(player.getUniqueId(), ignored -> Draft.empty());
+ List entries = selectorEntries(plugin, player, draft, mode);
+ int pages = pageCount(entries.size(), SELECTOR_SLOTS.length);
+ int page = Math.max(0, Math.min(requestedPage, pages - 1));
+ CreateHeadSelectorHolder holder = new CreateHeadSelectorHolder(mode, page);
+ Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(mode.title(), true));
+ holder.inventory(inventory);
+
+ fillBorder(plugin, inventory);
+ renderSelectorEntries(plugin, inventory, draft, mode, entries, page);
+ renderSelectorControls(plugin, inventory, draft, mode, entries.size(), page, pages);
+ player.openInventory(inventory);
+ plugin.sounds().play(player, SoundKey.MENU_OPEN);
+ }
+
+ private static void renderSelectorEntries(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, @NotNull Draft draft, @NotNull SelectorMode mode, @NotNull List entries, int page) {
+ int fromIndex = page * SELECTOR_SLOTS.length;
+ int toIndex = Math.min(entries.size(), fromIndex + SELECTOR_SLOTS.length);
+ int slotIndex = 0;
+
+ for (int index = fromIndex; index < toIndex; index++) {
+ SelectionEntry entry = entries.get(index);
+ ItemStack item = selectorItem(plugin, entry, isSelected(draft, mode, entry.id()));
+ stamp(plugin, item, ACTION_SELECTOR_ENTRY + entry.id());
+ inventory.setItem(SELECTOR_SLOTS[slotIndex], item);
+ slotIndex++;
+ }
+ }
+
+ private static void renderSelectorControls(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, @NotNull Draft draft, @NotNull SelectorMode mode, int entries, int page, int pages) {
+ setAction(plugin, inventory, SLOT_SELECTOR_BACK, "back", ACTION_SELECTOR_BACK);
+
+ if (page > 0) {
+ setAction(plugin, inventory, SLOT_SELECTOR_PREVIOUS, "previous", ACTION_SELECTOR_PREVIOUS);
+ }
+
+ inventory.setItem(SLOT_SELECTOR_INFO, GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("info"), GuiItems.name(mode.title(), NamedTextColor.GOLD), List.of(GuiItems.idDetail("Entries", entries), GuiItems.idDetail("Selected", selectedCount(draft, mode)), GuiItems.idDetail("Page", (page + 1) + " / " + Math.max(1, pages)))));
+
+ if (page + 1 < pages) {
+ setAction(plugin, inventory, SLOT_SELECTOR_NEXT, "next", ACTION_SELECTOR_NEXT);
+ }
+
+ if (selectedCount(draft, mode) > 0 || mode == SelectorMode.CATEGORY) {
+ setAction(plugin, inventory, SLOT_SELECTOR_CLEAR, "clear-filters", ACTION_SELECTOR_CLEAR);
+ }
+ }
+
+ private static @NotNull ItemStack selectorItem(@NotNull HeadDBPlugin plugin, @NotNull SelectionEntry entry, boolean selected) {
+ String iconKey = selected ? "filter-selected" : "filter-unselected";
+ ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey), GuiItems.name(entry.name(), selected ? NamedTextColor.GREEN : NamedTextColor.GOLD), List.of(GuiItems.idDetail("ID", entry.id()), GuiItems.lore(selected ? "Selected. Click to remove." : "Click to select.", selected ? NamedTextColor.GREEN : NamedTextColor.GRAY)));
+ return item;
+ }
+
+ private static void handleSelectorAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CreateHeadSelectorHolder holder, @NotNull String action) {
+ if (action.equals(ACTION_SELECTOR_BACK)) {
+ open(plugin, player);
+ return;
+ }
+
+ if (action.equals(ACTION_SELECTOR_PREVIOUS)) {
+ openSelector(plugin, player, holder.mode(), holder.page() - 1);
+ return;
+ }
+
+ if (action.equals(ACTION_SELECTOR_NEXT)) {
+ openSelector(plugin, player, holder.mode(), holder.page() + 1);
+ return;
+ }
+
+ Draft draft = DRAFTS.computeIfAbsent(player.getUniqueId(), ignored -> Draft.empty());
+ if (action.equals(ACTION_SELECTOR_CLEAR)) {
+ DRAFTS.put(player.getUniqueId(), clearSelection(draft, holder.mode()));
+ openSelector(plugin, player, holder.mode(), holder.page());
+ return;
+ }
+
+ if (!action.startsWith(ACTION_SELECTOR_ENTRY)) {
+ return;
+ }
+
+ String id = action.substring(ACTION_SELECTOR_ENTRY.length());
+ DRAFTS.put(player.getUniqueId(), toggleSelection(draft, holder.mode(), id));
+ if (holder.mode() == SelectorMode.CATEGORY) {
+ open(plugin, player);
+ return;
+ }
+
+ openSelector(plugin, player, holder.mode(), holder.page());
+ }
+
+ private static @NotNull Draft toggleSelection(@NotNull Draft draft, @NotNull SelectorMode mode, @NotNull String id) {
+ return switch (mode) {
+ case CATEGORY -> draft.withCategory(id);
+ case TAGS -> draft.withTags(toggle(draft.tags(), id));
+ case COLLECTIONS -> draft.withCollections(toggle(draft.collections(), id));
+ };
+ }
+
+ private static @NotNull Draft clearSelection(@NotNull Draft draft, @NotNull SelectorMode mode) {
+ return switch (mode) {
+ case CATEGORY -> draft.withCategory("custom");
+ case TAGS -> draft.withTags(Set.of());
+ case COLLECTIONS -> draft.withCollections(Set.of());
+ };
+ }
+
+ private static @NotNull Set toggle(@NotNull Set current, @NotNull String id) {
+ LinkedHashSet updated = new LinkedHashSet<>(current);
+ if (updated.contains(id)) {
+ updated.remove(id);
+ } else {
+ updated.add(id);
+ }
+ return Set.copyOf(updated);
+ }
+
+ private static boolean isSelected(@NotNull Draft draft, @NotNull SelectorMode mode, @NotNull String id) {
+ return switch (mode) {
+ case CATEGORY -> draft.category().equals(id);
+ case TAGS -> draft.tags().contains(id);
+ case COLLECTIONS -> draft.collections().contains(id);
+ };
+ }
+
+ private static int selectedCount(@NotNull Draft draft, @NotNull SelectorMode mode) {
+ return switch (mode) {
+ case CATEGORY -> draft.category().isBlank() ? 0 : 1;
+ case TAGS -> draft.tags().size();
+ case COLLECTIONS -> draft.collections().size();
+ };
+ }
+
+ private static @NotNull List selectorEntries(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Draft draft, @NotNull SelectorMode mode) {
+ Map entries = new java.util.LinkedHashMap<>();
+
+ switch (mode) {
+ case CATEGORY -> {
+ entries.put("custom", new SelectionEntry("custom", "Custom"));
+ plugin.headRegistry().categories().stream()
+ .filter(category -> Permissions.canViewCategory(player, category.id()))
+ .sorted(Comparator.comparing(HeadCategory::name, String.CASE_INSENSITIVE_ORDER))
+ .forEach(category -> entries.put(category.id(), new SelectionEntry(category.id(), category.name())));
+ boolean adminMode = plugin.adminModes().enabled(player);
+ plugin.customCategories().listVisible(adminMode).stream()
+ .filter(category -> Permissions.canViewCategory(player, category.id()))
+ .sorted(Comparator.comparing(CustomCategory::name, String.CASE_INSENSITIVE_ORDER))
+ .forEach(category -> entries.put(category.id(), new SelectionEntry(category.id(), category.draft() ? "DRAFT - " + category.name() : category.name())));
+ if (!draft.category().isBlank()) {
+ entries.putIfAbsent(draft.category(), new SelectionEntry(draft.category(), displayName(draft.category())));
+ }
+ }
+ case TAGS -> {
+ entries.put("custom", new SelectionEntry("custom", "Custom"));
+ plugin.headRegistry().tags().stream().sorted(Comparator.comparing(HeadTag::name, String.CASE_INSENSITIVE_ORDER)).forEach(tag -> entries.put(tag.id(), new SelectionEntry(tag.id(), tag.name())));
+ for (String tag : draft.tags()) {
+ entries.putIfAbsent(tag, new SelectionEntry(tag, displayName(tag)));
+ }
+ }
+ case COLLECTIONS -> {
+ plugin.headRegistry().collections().stream().sorted(Comparator.comparing(HeadCollection::name, String.CASE_INSENSITIVE_ORDER)).forEach(collection -> entries.put(collection.id(), new SelectionEntry(collection.id(), collection.name())));
+ for (String collection : draft.collections()) {
+ entries.putIfAbsent(collection, new SelectionEntry(collection, displayName(collection)));
+ }
+ }
+ }
+
+ return List.copyOf(entries.values());
+ }
+
+ private static void render(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, @NotNull Draft draft) {
+ inventory.setItem(slot(plugin, "create-head.preview", SLOT_PREVIEW), preview(plugin, draft));
+ setAction(plugin, inventory, slot(plugin, "create-head.use-held", SLOT_USE_HELD), "create-head-held", ACTION_USE_HELD);
+ setAction(plugin, inventory, slot(plugin, "create-head.reset", SLOT_START_FRESH), "create-head-fresh", ACTION_START_FRESH);
+ inventory.setItem(slot(plugin, "create-head.id", SLOT_ID), actionItem(plugin, "create-head-id", ACTION_ID, "ID", draft.id().isBlank() ? "Not set" : draft.id()));
+ inventory.setItem(slot(plugin, "create-head.name", SLOT_NAME), actionItem(plugin, "create-head-name", ACTION_NAME, "Name", draft.name().isBlank() ? "Not set" : draft.name()));
+ inventory.setItem(slot(plugin, "create-head.texture", SLOT_TEXTURE), actionItem(plugin, "create-head-texture", ACTION_TEXTURE, "Texture", draft.texture().isBlank() ? "Not set" : "set"));
+ inventory.setItem(slot(plugin, "create-head.category", SLOT_CATEGORY), actionItem(plugin, "create-head-category", ACTION_CATEGORY, "Category", draft.category().isBlank() ? "custom" : draft.category()));
+ inventory.setItem(slot(plugin, "create-head.tags", SLOT_TAGS), actionItem(plugin, "create-head-tags", ACTION_TAGS, "Tags", draft.tags().isEmpty() ? "none" : draft.tags().size()));
+ inventory.setItem(slot(plugin, "create-head.collections", SLOT_COLLECTIONS), actionItem(plugin, "create-head-collections", ACTION_COLLECTIONS, "Collections", draft.collections().isEmpty() ? "none" : draft.collections().size()));
+ setAction(plugin, inventory, slot(plugin, "create-head.save", SLOT_CREATE), "create-head-save", ACTION_CREATE);
+ setAction(plugin, inventory, slot(plugin, "create-head.back", SLOT_BACK), "back", ACTION_BACK);
+ }
+
+ private static int slot(@NotNull HeadDBPlugin plugin, @NotNull String key, int fallback) {
+ return plugin.guiConfig().slot(key, fallback);
+ }
+
+ private static @NotNull ItemStack preview(@NotNull HeadDBPlugin plugin, @NotNull Draft draft) {
+ ItemStack item = null;
+ if (!draft.texture().isBlank()) {
+ try {
+ String previewId = draft.id().trim().isBlank() ? "preview" : draft.id().trim();
+ String previewName = draft.name().trim().isBlank() ? displayName(previewId) : draft.name().trim();
+ String previewCategory = draft.category().trim().isBlank() ? "custom" : draft.category().trim();
+ StoredCustomHead head = new StoredCustomHead(previewId, previewName, draft.texture().trim(), null, List.of(), draft.tags(), draft.collections(), previewCategory, Instant.now(), Instant.now(), null, true);
+ item = plugin.itemFactory().create(head.toHead());
+ } catch (IllegalArgumentException ignored) {
+ item = null;
+ }
+ }
+
+ if (item == null) {
+ item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("create-head"));
+ }
+
+ item.editMeta(meta -> {
+ List lore = meta.lore() == null ? new ArrayList<>() : new ArrayList<>(meta.lore());
+ if (!lore.isEmpty()) {
+ lore.add(Component.empty());
+ }
+ lore.add(GuiItems.idDetail("ID", draft.id().isBlank() ? "Not set" : "custom:" + draft.id()));
+ lore.add(GuiItems.idDetail("Name", draft.name().isBlank() ? "Not set" : draft.name()));
+ lore.add(GuiItems.idDetail("Category", draft.category().isBlank() ? "custom" : draft.category()));
+ lore.add(GuiItems.idDetail("Texture", draft.texture().isBlank() ? "Not set" : "set"));
+ meta.lore(lore);
+ });
+ return item;
+ }
+
+ private static @NotNull ItemStack actionItem(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action, @NotNull String key, @NotNull Object value) {
+ java.util.List lore = new java.util.ArrayList<>(GuiItems.miniLore(plugin.guiConfig().icon(iconKey).lore()));
+ if (!lore.isEmpty()) {
+ lore.add(Component.empty());
+ }
+ lore.add(GuiItems.idDetail("Current", value));
+ lore.add(GuiItems.lore("Click to edit.", NamedTextColor.GREEN));
+
+ ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey), GuiItems.mini(plugin.guiConfig().icon(iconKey).name()), lore);
+ stamp(plugin, item, action);
+ return item;
+ }
+
+ private static void setAction(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, int slot, @NotNull String iconKey, @NotNull String action) {
+ ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey));
+ stamp(plugin, item, action);
+ inventory.setItem(slot, item);
+ }
+
+ private static void useHeld(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Draft draft) {
+ ItemStack held = player.getInventory().getItemInMainHand();
+ Optional headId = HeadItemIds.read(plugin, held);
+ if (headId.isPresent()) {
+ Optional head = plugin.headRegistry().find(headId.get());
+ if (head.isPresent()) {
+ Draft updated = draft.withTexture(head.get().texture().hash());
+ if (draft.id().isBlank() && !headId.get().isPlayer()) {
+ updated = updated.withId(headId.get().key());
+ }
+ if (draft.name().isBlank()) {
+ updated = updated.withName(head.get().name());
+ }
+ DRAFTS.put(player.getUniqueId(), updated);
+ player.getScheduler().run(plugin, task -> open(plugin, player), () -> {});
+ return;
+ }
+ }
+
+ try {
+ HeadTexture texture = new TextureInputParser().fromItem(held);
+ DRAFTS.put(player.getUniqueId(), draft.withTexture(texture.hash()));
+ player.getScheduler().run(plugin, task -> open(plugin, player), () -> {});
+ } catch (IllegalArgumentException exception) {
+ player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED));
+ plugin.sounds().play(player, SoundKey.INVALID);
+ }
+ }
+
+ public static void handleClose(@NotNull HeadDBPlugin plugin, @NotNull Player player, @Nullable InventoryHolder holder) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+
+ if (!(holder instanceof CreateHeadHolder) && !(holder instanceof CreateHeadSelectorHolder)) {
+ return;
+ }
+
+ Draft draft = DRAFTS.get(player.getUniqueId());
+ if (draft == null || !draft.canPersist()) {
+ return;
+ }
+
+ saveDraft(plugin, player, draft, false);
+ }
+
+ private static void saveDraft(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Draft draft, boolean reopenEdit) {
+ if (!Permissions.has(player, Permissions.CUSTOM_CREATE)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ try {
+ StoredCustomHead head = draft.toStored(player.getUniqueId()).withDraft(true);
+ plugin.headRegistry().customHeads().save(head);
+ plugin.headRegistry().onLocalMutation();
+ plugin.clearItemCache();
+ plugin.clearSearchCache();
+ if (!reopenEdit) {
+ return;
+ }
+
+ DRAFTS.remove(player.getUniqueId());
+ player.sendMessage(Component.text("Saved draft head: ", NamedTextColor.GRAY).append(Component.text(head.name() + " (" + head.headId().display() + ")", NamedTextColor.GOLD)));
+ plugin.sounds().play(player, SoundKey.SAVE_DRAFT);
+ HeadEditMenu.open(plugin, player, HeadId.custom(head.id()));
+ } catch (IllegalArgumentException exception) {
+ if (!reopenEdit) {
+ return;
+ }
+
+ player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED));
+ plugin.sounds().play(player, SoundKey.VALIDATION_ERROR);
+ open(plugin, player);
+ }
+ }
+
+ private static void prompt(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String message, @NotNull Consumer input) {
+ player.closeInventory();
+ player.getScheduler().run(plugin, task -> plugin.prompts().request(player, Component.text(message, NamedTextColor.GOLD), value -> {
+ input.accept(value);
+ open(plugin, player);
+ }, () -> open(plugin, player)), () -> {});
+ }
+
+ private static void update(@NotNull Player player, @NotNull Draft draft) {
+ DRAFTS.put(player.getUniqueId(), draft);
+ }
+
+ 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 = new ItemStack(material);
+ filler.editMeta(meta -> {
+ meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name()));
+ meta.lore(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 int pageCount(int entries, int pageSize) {
+ if (entries <= 0) {
+ return 1;
+ }
+ return (int) Math.ceil((double) entries / (double) pageSize);
+ }
+
+ 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, "create_head_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, SoundKey.NO_PERMISSION);
+ }
+
+ private record SelectionEntry(@NotNull String id, @NotNull String name) {}
+
+ private enum SelectorMode {
+ CATEGORY("Select Category"),
+ TAGS("Select Tags"),
+ COLLECTIONS("Select Collections");
+
+ private final String title;
+
+ SelectorMode(@NotNull String title) {
+ this.title = Objects.requireNonNull(title, "title");
+ }
+
+ private @NotNull String title() {
+ return title;
+ }
+ }
+
+ private record Draft(@NotNull String id, @NotNull String name, @NotNull String texture, @NotNull String category, @NotNull Set tags, @NotNull Set collections) {
+ private static @NotNull Draft empty() {
+ return new Draft("", "", "", "custom", Set.of("custom"), Set.of());
+ }
+
+ private @NotNull Draft withId(@NotNull String id) { return new Draft(id, name, texture, category, tags, collections); }
+ private @NotNull Draft withName(@NotNull String name) { return new Draft(id, name, texture, category, tags, collections); }
+ private @NotNull Draft withTexture(@NotNull String texture) { return new Draft(id, name, texture, category, tags, collections); }
+ private @NotNull Draft withCategory(@NotNull String category) { return new Draft(id, name, texture, category, tags, collections); }
+ private @NotNull Draft withTags(@NotNull Set tags) { return new Draft(id, name, texture, category, tags, collections); }
+ private @NotNull Draft withCollections(@NotNull Set collections) { return new Draft(id, name, texture, category, tags, collections); }
+
+ private boolean canPersist() {
+ return !id.trim().isBlank() && !texture.trim().isBlank();
+ }
+
+ private @NotNull StoredCustomHead toStored(@NotNull UUID createdBy) {
+ String cleanId = id.trim();
+ if (cleanId.isBlank()) {
+ throw new IllegalArgumentException("ID is required.");
+ }
+
+ if (texture.trim().isBlank()) {
+ throw new IllegalArgumentException("Texture is required.");
+ }
+
+ String cleanName = name.trim().isBlank() ? displayName(cleanId) : name.trim();
+ String cleanCategory = category.trim().isBlank() ? "custom" : category.trim();
+ return new StoredCustomHead(cleanId, cleanName, texture.trim(), null, List.of(), tags, collections, cleanCategory, Instant.now(), Instant.now(), createdBy);
+ }
+ }
+
+ private static @NotNull String displayName(@NotNull String id) {
+ String normalized = id.trim().replace('_', '-');
+ if (normalized.isBlank()) {
+ return "Custom Head";
+ }
+
+ String[] parts = normalized.split("-");
+ java.util.List words = new java.util.ArrayList<>();
+ for (String part : parts) {
+ if (part.isBlank()) {
+ continue;
+ }
+ words.add(Character.toUpperCase(part.charAt(0)) + part.substring(1));
+ }
+ return words.isEmpty() ? normalized : String.join(" ", words);
+ }
+
+ private static final class CreateHeadSelectorHolder implements InventoryHolder {
+ private final SelectorMode mode;
+ private final int page;
+ private Inventory inventory;
+
+ private CreateHeadSelectorHolder(@NotNull SelectorMode mode, int page) {
+ this.mode = Objects.requireNonNull(mode, "mode");
+ this.page = page;
+ }
+
+ private @NotNull SelectorMode mode() {
+ return mode;
+ }
+
+ private int page() {
+ return page;
+ }
+
+ private void inventory(@NotNull Inventory inventory) {
+ this.inventory = inventory;
+ }
+
+ @Override
+ public @NotNull Inventory getInventory() {
+ return inventory;
+ }
+ }
+
+ private static final class CreateHeadHolder implements InventoryHolder {
+ private Inventory inventory;
+
+ private void inventory(@NotNull Inventory inventory) {
+ this.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/CreateTaxonomyMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateTaxonomyMenu.java
new file mode 100644
index 0000000..91fc401
--- /dev/null
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CreateTaxonomyMenu.java
@@ -0,0 +1,320 @@
+package io.github.silentdevelopment.headdb.paper.gui.category;
+
+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.local.taxonomy.CustomTaxonomyEntry;
+import io.github.silentdevelopment.headdb.paper.message.MessageKey;
+import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
+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.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+
+public final class CreateTaxonomyMenu {
+
+ private static final int SIZE = 54;
+ private static final int ROWS = 6;
+ private static final int SLOT_PREVIEW = 4;
+ private static final int SLOT_ID = 20;
+ private static final int SLOT_NAME = 22;
+ private static final int SLOT_DESCRIPTION = 24;
+ private static final int SLOT_SAVE = 40;
+ private static final int SLOT_BACK = 45;
+ private static final String ACTION_BACK = "back";
+ private static final String ACTION_ID = "id";
+ private static final String ACTION_NAME = "name";
+ private static final String ACTION_DESCRIPTION = "description";
+ private static final String ACTION_SAVE = "save";
+ private static final Map DRAFTS = new ConcurrentHashMap<>();
+
+ private CreateTaxonomyMenu() {
+ throw new UnsupportedOperationException("This class cannot be instantiated.");
+ }
+
+ public static void openTag(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ Objects.requireNonNull(player, "player");
+ DRAFTS.put(new DraftKey(player.getUniqueId(), Mode.TAG), Draft.empty(Mode.TAG));
+ open(plugin, player, Mode.TAG);
+ }
+
+ public static void openCollection(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ Objects.requireNonNull(player, "player");
+ DRAFTS.put(new DraftKey(player.getUniqueId(), Mode.COLLECTION), Draft.empty(Mode.COLLECTION));
+ open(plugin, player, Mode.COLLECTION);
+ }
+
+ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Mode mode) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(mode, "mode");
+
+ if (!canUse(plugin, player, mode)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ Draft draft = DRAFTS.computeIfAbsent(new DraftKey(player.getUniqueId(), mode), ignored -> Draft.empty(mode));
+ Holder holder = new Holder(mode);
+ Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(plugin.guiConfig().text(mode.titleKey(), mode.titleFallback()), true));
+ holder.inventory(inventory);
+
+ fillBorder(plugin, inventory);
+ render(plugin, inventory, draft, mode);
+ player.openInventory(inventory);
+ plugin.sounds().play(player, SoundKey.MENU_OPEN);
+ }
+
+ 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() 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);
+ action.ifPresent(value -> {
+ if (!value.equals(ACTION_SAVE)) {
+ plugin.sounds().playGuiAction(player, value);
+ }
+ handleAction(plugin, player, holder.mode(), value);
+ });
+ return true;
+ }
+
+ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Mode mode, @NotNull String action) {
+ DraftKey key = new DraftKey(player.getUniqueId(), mode);
+ Draft draft = DRAFTS.computeIfAbsent(key, ignored -> Draft.empty(mode));
+
+ if (action.equals(ACTION_BACK)) {
+ back(plugin, player, mode);
+ return;
+ }
+
+ if (action.equals(ACTION_ID)) {
+ prompt(plugin, player, mode, "Enter the " + mode.displayName() + " id.", value -> draft.withId(value));
+ return;
+ }
+
+ if (action.equals(ACTION_NAME)) {
+ prompt(plugin, player, mode, "Enter the " + mode.displayName() + " display name.", value -> draft.withName(value));
+ return;
+ }
+
+ if (action.equals(ACTION_DESCRIPTION)) {
+ prompt(plugin, player, mode, "Enter the " + mode.displayName() + " description.", value -> draft.withDescription(value));
+ return;
+ }
+
+ if (action.equals(ACTION_SAVE)) {
+ save(plugin, player, mode, key, draft);
+ }
+ }
+
+ private static void prompt(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Mode mode, @NotNull String message, @NotNull Function update) {
+ player.closeInventory();
+ plugin.prompts().request(player, Component.text(message, NamedTextColor.GOLD), value -> {
+ DRAFTS.put(new DraftKey(player.getUniqueId(), mode), update.apply(value));
+ open(plugin, player, mode);
+ }, () -> open(plugin, player, mode));
+ }
+
+ private static void save(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Mode mode, @NotNull DraftKey key, @NotNull Draft draft) {
+ if (!canUse(plugin, player, mode)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ try {
+ CustomTaxonomyEntry entry = new CustomTaxonomyEntry(draft.id(), draft.name(), draft.description(), player.getUniqueId());
+ switch (mode) {
+ case TAG -> plugin.customTags().save(entry);
+ case COLLECTION -> plugin.customCollections().save(entry);
+ }
+ DRAFTS.remove(key);
+ plugin.headRegistry().onLocalMutation();
+ plugin.clearSearchCache();
+ player.sendMessage(plugin.messages().taxonomyCreated(player, mode.displayName().toLowerCase(java.util.Locale.ROOT), entry.name(), entry.id()));
+ plugin.sounds().play(player, mode == Mode.TAG ? SoundKey.TAG_CREATE : SoundKey.COLLECTION_CREATE);
+ back(plugin, player, mode);
+ } catch (IllegalArgumentException exception) {
+ player.sendMessage(Component.text(exception.getMessage(), NamedTextColor.RED));
+ plugin.sounds().play(player, SoundKey.VALIDATION_ERROR);
+ open(plugin, player, mode);
+ }
+ }
+
+ private static void back(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Mode mode) {
+ switch (mode) {
+ case TAG -> TagsMenu.open(plugin, player);
+ case COLLECTION -> CollectionsMenu.open(plugin, player);
+ }
+ }
+
+ private static void render(@NotNull HeadDBPlugin plugin, @NotNull Inventory inventory, @NotNull Draft draft, @NotNull Mode mode) {
+ inventory.setItem(SLOT_PREVIEW, GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(mode.iconKey()), GuiItems.name(mode.titleFallback(), NamedTextColor.GOLD), List.of(GuiItems.idDetail("ID", valueOrUnset(draft.id())), GuiItems.idDetail("Name", valueOrUnset(draft.name())))));
+ inventory.setItem(plugin.guiConfig().slot("taxonomy.id", SLOT_ID), actionItem(plugin, "taxonomy-id", ACTION_ID, "ID", valueOrUnset(draft.id())));
+ inventory.setItem(plugin.guiConfig().slot("taxonomy.name", SLOT_NAME), actionItem(plugin, "taxonomy-name", ACTION_NAME, "Name", valueOrUnset(draft.name())));
+ inventory.setItem(plugin.guiConfig().slot("taxonomy.description", SLOT_DESCRIPTION), actionItem(plugin, "taxonomy-description", ACTION_DESCRIPTION, "Description", valueOrUnset(draft.description())));
+ inventory.setItem(plugin.guiConfig().slot("taxonomy.save", SLOT_SAVE), actionOnly(plugin, "taxonomy-save", ACTION_SAVE));
+ inventory.setItem(plugin.guiConfig().slot("taxonomy.back", SLOT_BACK), actionOnly(plugin, "back", ACTION_BACK));
+ }
+
+ private static @NotNull ItemStack actionItem(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action, @NotNull String key, @NotNull Object value) {
+ ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey), GuiItems.mini(plugin.guiConfig().icon(iconKey).name()), List.of(GuiItems.idDetail(key, value), GuiItems.lore("Click to edit.", NamedTextColor.GREEN)));
+ stamp(plugin, item, action);
+ return item;
+ }
+
+ private static @NotNull ItemStack actionOnly(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action) {
+ ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey));
+ stamp(plugin, item, action);
+ return item;
+ }
+
+ private static boolean canUse(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Mode mode) {
+ return plugin.adminModes().enabled(player) && Permissions.has(player, mode.permission());
+ }
+
+ private static @NotNull String valueOrUnset(@NotNull String value) {
+ return value.isBlank() ? "Not set" : value;
+ }
+
+ 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 = new ItemStack(material);
+ filler.editMeta(meta -> {
+ meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name()));
+ meta.lore(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, "taxonomy_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, SoundKey.NO_PERMISSION);
+ }
+
+ private enum Mode {
+ TAG("tag", "Tag", "create-tag", "title.create-tag", "Create Tag", Permissions.GUI_CREATE_TAG),
+ COLLECTION("collection", "Collection", "create-collection", "title.create-collection", "Create Collection", Permissions.GUI_CREATE_COLLECTION);
+
+ private final String id;
+ private final String displayName;
+ private final String iconKey;
+ private final String titleKey;
+ private final String titleFallback;
+ private final String permission;
+
+ Mode(@NotNull String id, @NotNull String displayName, @NotNull String iconKey, @NotNull String titleKey, @NotNull String titleFallback, @NotNull String permission) {
+ this.id = Objects.requireNonNull(id, "id");
+ this.displayName = Objects.requireNonNull(displayName, "displayName");
+ this.iconKey = Objects.requireNonNull(iconKey, "iconKey");
+ this.titleKey = Objects.requireNonNull(titleKey, "titleKey");
+ this.titleFallback = Objects.requireNonNull(titleFallback, "titleFallback");
+ this.permission = Objects.requireNonNull(permission, "permission");
+ }
+
+ private @NotNull String displayName() { return displayName; }
+ private @NotNull String iconKey() { return iconKey; }
+ private @NotNull String titleKey() { return titleKey; }
+ private @NotNull String titleFallback() { return titleFallback; }
+ private @NotNull String permission() { return permission; }
+ }
+
+ private record Draft(@NotNull String id, @NotNull String name, @NotNull String description) {
+ private static @NotNull Draft empty(@NotNull Mode mode) {
+ return new Draft("", "", "Local custom " + mode.displayName().toLowerCase(java.util.Locale.ROOT) + ".");
+ }
+
+ private @NotNull Draft withId(@NotNull String id) { return new Draft(id, name, description); }
+ private @NotNull Draft withName(@NotNull String name) { return new Draft(id, name, description); }
+ private @NotNull Draft withDescription(@NotNull String description) { return new Draft(id, name, description); }
+ }
+
+ private record DraftKey(@NotNull UUID playerId, @NotNull Mode mode) {}
+
+ private static final class Holder implements InventoryHolder {
+ private final Mode mode;
+ private Inventory inventory;
+
+ private Holder(@NotNull Mode mode) {
+ this.mode = Objects.requireNonNull(mode, "mode");
+ }
+
+ private @NotNull Mode mode() {
+ return mode;
+ }
+
+ private void inventory(@NotNull Inventory inventory) {
+ this.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/CustomCategory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategory.java
index 7edb53b..286d319 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategory.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategory.java
@@ -1,5 +1,6 @@
package io.github.silentdevelopment.headdb.paper.gui.category;
+import io.github.silentdevelopment.headdb.model.HeadCategory;
import io.github.silentdevelopment.headdb.model.HeadId;
import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials;
import org.bukkit.Material;
@@ -10,21 +11,27 @@
import java.util.Objects;
import java.util.Set;
-public record CustomCategory(@NotNull String id, @NotNull String name, @NotNull String material, @NotNull Set headIds) {
+public record CustomCategory(@NotNull String id, @NotNull String name, @NotNull String material, @NotNull String description, boolean draft, @NotNull Set headIds) {
public CustomCategory(@NotNull String id, @NotNull String name, @NotNull String material) {
- this(id, name, material, Set.of());
+ this(id, name, material, "Local custom category.", false, Set.of());
+ }
+
+ public CustomCategory(@NotNull String id, @NotNull String name, @NotNull String material, @NotNull Set headIds) {
+ this(id, name, material, "Local custom category.", false, headIds);
}
public CustomCategory {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(name, "name");
Objects.requireNonNull(material, "material");
+ Objects.requireNonNull(description, "description");
Objects.requireNonNull(headIds, "headIds");
id = id.trim().toLowerCase(Locale.ROOT);
name = name.trim();
material = normalizeIcon(material);
+ description = description.trim().isBlank() ? "Local custom category." : description.trim();
headIds = Set.copyOf(new LinkedHashSet<>(headIds));
if (id.isBlank()) {
@@ -48,6 +55,31 @@ public boolean headIcon() {
return material.substring("HEAD:".length());
}
+ public @NotNull CustomCategory withName(@NotNull String name) {
+ return new CustomCategory(id, name, material, description, draft, headIds);
+ }
+
+ public @NotNull CustomCategory withMaterial(@NotNull String material) {
+ return new CustomCategory(id, name, material, description, draft, headIds);
+ }
+
+ public @NotNull CustomCategory withDescription(@NotNull String description) {
+ return new CustomCategory(id, name, material, description, draft, headIds);
+ }
+
+ public @NotNull CustomCategory withDraft(boolean draft) {
+ return new CustomCategory(id, name, material, description, draft, headIds);
+ }
+
+ public @NotNull CustomCategory withHeadIds(@NotNull Set headIds) {
+ return new CustomCategory(id, name, material, description, draft, headIds);
+ }
+
+ public @NotNull HeadCategory toHeadCategory() {
+ String displayName = draft ? "[DRAFT] " + name : name;
+ return new HeadCategory(id, displayName, description);
+ }
+
private static @NotNull String normalizeIcon(@NotNull String value) {
String trimmed = value.trim();
if (trimmed.toUpperCase(Locale.ROOT).startsWith("HEAD:")) {
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryService.java
index 8a086bd..482daab 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryService.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryService.java
@@ -22,6 +22,7 @@
public final class CustomCategoryService {
private final SQLiteDataSource dataSource;
+ private List cache;
public CustomCategoryService(@NotNull Path databaseFile) {
Objects.requireNonNull(databaseFile, "databaseFile");
@@ -30,36 +31,42 @@ public CustomCategoryService(@NotNull Path databaseFile) {
}
public synchronized @NotNull List list() {
- List categories = new ArrayList<>();
- try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, material FROM headdb_custom_categories ORDER BY lower(name) ASC")) {
- try (ResultSet result = statement.executeQuery()) {
- while (result.next()) {
- String id = result.getString("id");
- categories.add(new CustomCategory(id, result.getString("name"), result.getString("material"), headIds(connection, id)));
- }
- }
- } catch (SQLException exception) {
- throw new IllegalStateException("Failed to list HeadDB custom categories.", exception);
+ List current = cache;
+ if (current != null) {
+ return current;
}
+ current = loadAll();
+ cache = current;
+ return current;
+ }
- categories.sort(Comparator.comparing(CustomCategory::name, String.CASE_INSENSITIVE_ORDER));
- return List.copyOf(categories);
+
+ public synchronized @NotNull List listPublished() {
+ return list().stream().filter(category -> !category.draft()).toList();
+ }
+
+ public synchronized @NotNull List listVisible(boolean includeDrafts) {
+ if (includeDrafts) {
+ return list();
+ }
+
+ return listPublished();
+ }
+
+ private static @NotNull CustomCategory categoryFromRow(@NotNull ResultSet result, @NotNull Set headIds) throws SQLException {
+ String description = result.getString("description");
+ boolean draft = result.getBoolean("draft");
+ return new CustomCategory(result.getString("id"), result.getString("name"), result.getString("material"), description == null ? "Local custom category." : description, draft, headIds);
}
public synchronized @NotNull String nextId() {
int next = 1;
- try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id FROM headdb_custom_categories")) {
- try (ResultSet result = statement.executeQuery()) {
- while (result.next()) {
- try {
- next = Math.max(next, Integer.parseInt(result.getString("id")) + 1);
- } catch (NumberFormatException ignored) {
- // Non-numeric legacy IDs are ignored for automatic assignment.
- }
- }
+ for (CustomCategory category : list()) {
+ try {
+ next = Math.max(next, Integer.parseInt(category.id()) + 1);
+ } catch (NumberFormatException ignored) {
+ // Non-numeric legacy IDs are ignored for automatic assignment.
}
- } catch (SQLException exception) {
- throw new IllegalStateException("Failed to calculate next HeadDB custom category id.", exception);
}
while (find(String.valueOf(next)).isPresent()) {
@@ -72,19 +79,7 @@ public CustomCategoryService(@NotNull Path databaseFile) {
public synchronized @NotNull Optional find(@NotNull String id) {
Objects.requireNonNull(id, "id");
String normalized = normalize(id);
-
- try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, material FROM headdb_custom_categories WHERE id = ?")) {
- statement.setString(1, normalized);
- try (ResultSet result = statement.executeQuery()) {
- if (!result.next()) {
- return Optional.empty();
- }
-
- return Optional.of(new CustomCategory(result.getString("id"), result.getString("name"), result.getString("material"), headIds(connection, normalized)));
- }
- } catch (SQLException exception) {
- throw new IllegalStateException("Failed to read HeadDB custom category " + normalized + ".", exception);
- }
+ return list().stream().filter(category -> category.id().equals(normalized)).findFirst();
}
public synchronized void save(@NotNull CustomCategory category) {
@@ -92,10 +87,12 @@ public synchronized void save(@NotNull CustomCategory category) {
try (Connection connection = dataSource.getConnection()) {
connection.setAutoCommit(false);
- try (PreparedStatement statement = connection.prepareStatement("INSERT INTO headdb_custom_categories(id, name, material) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, material = excluded.material")) {
+ try (PreparedStatement statement = connection.prepareStatement("INSERT INTO headdb_custom_categories(id, name, material, description, draft) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, material = excluded.material, description = excluded.description, draft = excluded.draft")) {
statement.setString(1, category.id());
statement.setString(2, category.name());
statement.setString(3, category.material());
+ statement.setString(4, category.description());
+ statement.setBoolean(5, category.draft());
statement.executeUpdate();
}
@@ -114,6 +111,7 @@ public synchronized void save(@NotNull CustomCategory category) {
}
connection.commit();
+ cache = null;
} catch (SQLException exception) {
throw new IllegalStateException("Failed to save HeadDB custom category " + category.id() + ".", exception);
}
@@ -124,7 +122,7 @@ public synchronized void addHead(@NotNull String categoryId, @NotNull HeadId hea
CustomCategory category = find(categoryId).orElseThrow(() -> new IllegalArgumentException("Unknown custom category: " + categoryId));
LinkedHashSet ids = new LinkedHashSet<>(category.headIds());
ids.add(headId);
- save(new CustomCategory(category.id(), category.name(), category.material(), ids));
+ save(category.withHeadIds(ids));
}
public synchronized void removeHead(@NotNull String categoryId, @NotNull HeadId headId) {
@@ -132,7 +130,7 @@ public synchronized void removeHead(@NotNull String categoryId, @NotNull HeadId
CustomCategory category = find(categoryId).orElseThrow(() -> new IllegalArgumentException("Unknown custom category: " + categoryId));
LinkedHashSet ids = new LinkedHashSet<>(category.headIds());
ids.remove(headId);
- save(new CustomCategory(category.id(), category.name(), category.material(), ids));
+ save(category.withHeadIds(ids));
}
public synchronized boolean delete(@NotNull String id) {
@@ -151,12 +149,33 @@ public synchronized boolean delete(@NotNull String id) {
updated = category.executeUpdate();
}
connection.commit();
- return updated > 0;
+ if (updated > 0) {
+ cache = null;
+ return true;
+ }
+ return false;
} catch (SQLException exception) {
throw new IllegalStateException("Failed to delete HeadDB custom category " + normalized + ".", exception);
}
}
+ private @NotNull List loadAll() {
+ List categories = new ArrayList<>();
+ try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT id, name, material, description, draft FROM headdb_custom_categories ORDER BY lower(name) ASC")) {
+ try (ResultSet result = statement.executeQuery()) {
+ while (result.next()) {
+ String id = result.getString("id");
+ categories.add(categoryFromRow(result, headIds(connection, id)));
+ }
+ }
+ } catch (SQLException exception) {
+ throw new IllegalStateException("Failed to list HeadDB custom categories.", exception);
+ }
+
+ categories.sort(Comparator.comparing(CustomCategory::name, String.CASE_INSENSITIVE_ORDER));
+ return List.copyOf(categories);
+ }
+
private @NotNull Set headIds(@NotNull Connection connection, @NotNull String categoryId) throws SQLException {
LinkedHashSet ids = new LinkedHashSet<>();
try (PreparedStatement statement = connection.prepareStatement("SELECT head_id FROM headdb_custom_category_heads WHERE category_id = ? ORDER BY rowid ASC")) {
@@ -177,9 +196,11 @@ public synchronized boolean delete(@NotNull String id) {
private void createSchema() {
try (Connection connection = dataSource.getConnection()) {
- try (PreparedStatement categories = connection.prepareStatement("CREATE TABLE IF NOT EXISTS headdb_custom_categories (id TEXT PRIMARY KEY, name TEXT NOT NULL, material TEXT NOT NULL)")) {
+ try (PreparedStatement categories = connection.prepareStatement("CREATE TABLE IF NOT EXISTS headdb_custom_categories (id TEXT PRIMARY KEY, name TEXT NOT NULL, material TEXT NOT NULL, description TEXT NOT NULL DEFAULT 'Local custom category.', draft INTEGER NOT NULL DEFAULT 0)")) {
categories.executeUpdate();
}
+ addColumnIfMissing(connection, "headdb_custom_categories", "description", "TEXT NOT NULL DEFAULT 'Local custom category.'");
+ addColumnIfMissing(connection, "headdb_custom_categories", "draft", "INTEGER NOT NULL DEFAULT 0");
try (PreparedStatement heads = connection.prepareStatement("CREATE TABLE IF NOT EXISTS headdb_custom_category_heads (category_id TEXT NOT NULL, head_id TEXT NOT NULL, PRIMARY KEY (category_id, head_id))")) {
heads.executeUpdate();
}
@@ -188,6 +209,19 @@ private void createSchema() {
}
}
+
+ private static void addColumnIfMissing(@NotNull Connection connection, @NotNull String table, @NotNull String column, @NotNull String definition) throws SQLException {
+ try (ResultSet result = connection.getMetaData().getColumns(null, null, table, column)) {
+ if (result.next()) {
+ return;
+ }
+ }
+
+ try (PreparedStatement statement = connection.prepareStatement("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition)) {
+ statement.executeUpdate();
+ }
+ }
+
private static @NotNull SQLiteDataSource dataSource(@NotNull Path databaseFile) {
try {
Path parent = databaseFile.toAbsolutePath().normalize().getParent();
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryViewMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryViewMenu.java
new file mode 100644
index 0000000..6127db6
--- /dev/null
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/CustomCategoryViewMenu.java
@@ -0,0 +1,290 @@
+package io.github.silentdevelopment.headdb.paper.gui.category;
+
+import io.github.silentdevelopment.headdb.model.Head;
+import io.github.silentdevelopment.headdb.model.HeadId;
+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.item.HeadItemIds;
+import io.github.silentdevelopment.headdb.paper.message.MessageKey;
+import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextDecoration;
+import org.bukkit.Bukkit;
+import org.bukkit.Material;
+import org.bukkit.NamespacedKey;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.ClickType;
+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.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+public final class CustomCategoryViewMenu {
+
+ 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 String ACTION_BACK = "back";
+ private static final String ACTION_PREVIOUS = "previous";
+ private static final String ACTION_NEXT = "next";
+ private static final int[] HEAD_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 CustomCategoryViewMenu() {
+ throw new UnsupportedOperationException("This class cannot be instantiated.");
+ }
+
+ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String categoryId, int requestedPage) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(categoryId, "categoryId");
+
+ Optional existing = plugin.customCategories().find(categoryId);
+ if (existing.isEmpty()) {
+ plugin.guis().openBrowse(player);
+ return;
+ }
+
+ CustomCategory category = existing.get();
+ boolean adminMode = plugin.adminModes().enabled(player);
+ if (category.draft() && !adminMode) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ if (!Permissions.canViewCategory(player, category.id())) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ List heads = heads(plugin, category, adminMode);
+ int pages = pageCount(heads.size());
+ int page = Math.max(0, Math.min(requestedPage, pages - 1));
+ Holder holder = new Holder(category.id(), page);
+ Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(category.draft() ? "DRAFT - " + category.name() : category.name(), adminMode));
+ holder.inventory(inventory);
+
+ fillBorder(plugin, inventory);
+ renderHeads(plugin, player, inventory, heads, page, adminMode);
+ renderControls(plugin, player, inventory, category, heads.size(), page, pages);
+ player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
+ }
+
+ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event, @NotNull Consumer edit, @NotNull Consumer give) {
+ if (!(event.getView().getTopInventory().getHolder() 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.isPresent()) {
+ plugin.sounds().playGuiAction(player, action.get());
+ handleAction(plugin, player, holder, action.get());
+ return true;
+ }
+
+ Optional id = HeadItemIds.read(plugin, item);
+ if (id.isEmpty()) {
+ return true;
+ }
+
+ if ((event.getClick() == ClickType.DROP || event.getClick() == ClickType.CONTROL_DROP) && plugin.adminModes().enabled(player)) {
+ edit.accept(id.get());
+ return true;
+ }
+
+ if (event.getClick() == ClickType.RIGHT || event.getClick() == ClickType.SHIFT_RIGHT) {
+ if (!Permissions.has(player, Permissions.FAVORITES_TOGGLE)) {
+ noPermission(plugin, player);
+ return true;
+ }
+
+ plugin.favorites().toggle(player.getUniqueId(), id.get());
+ open(plugin, player, holder.categoryId(), holder.page());
+ return true;
+ }
+
+ give.accept(item);
+ return true;
+ }
+
+ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull String action) {
+ if (action.equals(ACTION_BACK)) {
+ plugin.guis().openBrowse(player);
+ return;
+ }
+
+ if (action.equals(ACTION_PREVIOUS)) {
+ open(plugin, player, holder.categoryId(), holder.page() - 1);
+ return;
+ }
+
+ if (action.equals(ACTION_NEXT)) {
+ open(plugin, player, holder.categoryId(), holder.page() + 1);
+ return;
+ }
+ }
+
+ private static void renderHeads(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull List heads, int page, boolean adminMode) {
+ int fromIndex = page * HEAD_SLOTS.length;
+ int toIndex = Math.min(heads.size(), fromIndex + HEAD_SLOTS.length);
+ int slotIndex = 0;
+
+ for (int index = fromIndex; index < toIndex; index++) {
+ Head head = heads.get(index);
+ ItemStack item = plugin.itemFactory().create(head);
+ plugin.favorites().decorate(player.getUniqueId(), head.id(), item);
+ if (adminMode) {
+ item.editMeta(meta -> {
+ List lore = meta.lore() == null ? new ArrayList<>() : new ArrayList<>(meta.lore());
+ lore.add(Component.empty());
+ lore.add(GuiItems.idDetail("ID", head.id().display()));
+ lore.add(GuiItems.metaDetail("Category", head.category()));
+ lore.add(Component.text("Press ", NamedTextColor.GRAY).append(Component.keybind("key.drop", NamedTextColor.GOLD)).append(Component.text(" to edit.", NamedTextColor.GRAY)).decoration(TextDecoration.ITALIC, false));
+ meta.lore(lore);
+ });
+ }
+ inventory.setItem(HEAD_SLOTS[slotIndex], item);
+ slotIndex++;
+ }
+ }
+
+ private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull CustomCategory category, int totalHeads, int page, int pages) {
+ inventory.setItem(SLOT_BACK, action(plugin, ACTION_BACK, "back"));
+ inventory.setItem(SLOT_INFO, GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("info"), GuiItems.name("Info", NamedTextColor.GOLD), List.of(GuiItems.idDetail("Heads", totalHeads), GuiItems.idDetail("ID", category.id()), GuiItems.idDetail("Page", (page + 1) + " / " + Math.max(1, pages)))));
+ if (page > 0) {
+ inventory.setItem(SLOT_PREVIOUS, action(plugin, ACTION_PREVIOUS, "previous"));
+ }
+ if (page + 1 < pages) {
+ inventory.setItem(SLOT_NEXT, action(plugin, ACTION_NEXT, "next"));
+ }
+ }
+
+ private static @NotNull List heads(@NotNull HeadDBPlugin plugin, @NotNull CustomCategory category, boolean adminMode) {
+ List heads = new ArrayList<>();
+ for (HeadId id : category.headIds()) {
+ Optional head = id.isCustom() && adminMode ? plugin.headRegistry().customHeads().findStored(id).map(stored -> stored.toHead()) : plugin.headRegistry().find(id);
+ head.ifPresent(heads::add);
+ }
+ heads.sort(Comparator.comparing(Head::name, String.CASE_INSENSITIVE_ORDER));
+ return List.copyOf(heads);
+ }
+
+ private static @NotNull ItemStack action(@NotNull HeadDBPlugin plugin, @NotNull String action, @NotNull String iconKey) {
+ ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey));
+ stamp(plugin, item, action);
+ return item;
+ }
+
+ 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 = new ItemStack(material);
+ filler.editMeta(meta -> {
+ meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name()));
+ meta.lore(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, "custom_category_view_action");
+ }
+
+ private static int pageCount(int entries) {
+ if (entries <= 0) {
+ return 1;
+ }
+ return (int) Math.ceil((double) entries / (double) HEAD_SLOTS.length);
+ }
+
+ 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 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 = 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/DeleteCategoryConfirmMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/DeleteCategoryConfirmMenu.java
new file mode 100644
index 0000000..f346566
--- /dev/null
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/DeleteCategoryConfirmMenu.java
@@ -0,0 +1,176 @@
+package io.github.silentdevelopment.headdb.paper.gui.category;
+
+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.message.MessageKey;
+import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
+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.List;
+import java.util.Objects;
+import java.util.Optional;
+
+public final class DeleteCategoryConfirmMenu {
+
+ private static final int SIZE = 27;
+ private static final String ACTION_CONFIRM = "confirm";
+ private static final String ACTION_CANCEL = "cancel";
+
+ private DeleteCategoryConfirmMenu() {
+ throw new UnsupportedOperationException("This class cannot be instantiated.");
+ }
+
+ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String categoryId) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(categoryId, "categoryId");
+
+ if (!canDelete(plugin, player)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ Optional category = plugin.customCategories().find(categoryId);
+ if (category.isEmpty()) {
+ plugin.guis().openBrowse(player);
+ return;
+ }
+
+ Holder holder = new Holder(category.get().id());
+ Inventory inventory = Bukkit.createInventory(holder, SIZE, Component.text("Delete Category", NamedTextColor.RED));
+ holder.inventory(inventory);
+ fill(inventory);
+ inventory.setItem(11, action(plugin, "confirm-yes", ACTION_CONFIRM, category.get()));
+ inventory.setItem(15, action(plugin, "confirm-no", ACTION_CANCEL, category.get()));
+ player.openInventory(inventory);
+ plugin.sounds().play(player, SoundKey.MENU_OPEN);
+ }
+
+ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) {
+ if (!(event.getView().getTopInventory().getHolder() 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;
+ }
+
+ if (action.get().equals(ACTION_CANCEL)) {
+ plugin.sounds().play(player, SoundKey.CANCEL);
+ CreateCategoryMenu.openExisting(plugin, player, holder.categoryId());
+ return true;
+ }
+
+ if (!action.get().equals(ACTION_CONFIRM)) {
+ return true;
+ }
+
+ if (!canDelete(plugin, player)) {
+ noPermission(plugin, player);
+ return true;
+ }
+
+ Optional category = plugin.customCategories().find(holder.categoryId());
+ String label = category.map(value -> categoryLabel(plugin, player, value)).orElse(holder.categoryId());
+ boolean deleted = plugin.customCategories().delete(holder.categoryId());
+ plugin.clearSearchCache();
+ player.sendMessage(Component.text(deleted ? "Category deleted: " : "Category already deleted: ", deleted ? NamedTextColor.GRAY : NamedTextColor.RED).append(Component.text(label, NamedTextColor.GOLD)));
+ plugin.sounds().play(player, deleted ? SoundKey.CATEGORY_DELETE : SoundKey.INVALID);
+ plugin.guis().openBrowse(player);
+ return true;
+ }
+
+ private static @NotNull String categoryLabel(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CustomCategory category) {
+ return plugin.adminModes().enabled(player) || Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN) ? category.name() + " (" + category.id() + ")" : category.name();
+ }
+
+ private static @NotNull ItemStack action(@NotNull HeadDBPlugin plugin, @NotNull String iconKey, @NotNull String action, @NotNull CustomCategory category) {
+ ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(iconKey), GuiItems.mini(plugin.guiConfig().icon(iconKey).name()), List.of(GuiItems.idDetail("Category", category.name()), GuiItems.idDetail("ID", category.id())));
+ stamp(plugin, item, action);
+ return item;
+ }
+
+ private static boolean canDelete(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ return plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN);
+ }
+
+ private static void fill(@NotNull Inventory inventory) {
+ ItemStack item = GuiItems.item(Material.BLACK_STAINED_GLASS_PANE, Component.empty(), List.of());
+ for (int slot = 0; slot < inventory.getSize(); slot++) {
+ inventory.setItem(slot, item.clone());
+ }
+ }
+
+ 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, "delete_category_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, SoundKey.NO_PERMISSION);
+ }
+
+ private static final class Holder implements InventoryHolder {
+ private final String categoryId;
+ private Inventory inventory;
+
+ private Holder(@NotNull String categoryId) {
+ this.categoryId = Objects.requireNonNull(categoryId, "categoryId");
+ }
+
+ private @NotNull String categoryId() {
+ return categoryId;
+ }
+
+ private void inventory(@NotNull Inventory inventory) {
+ this.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/MoreCategoriesMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/MoreCategoriesMenu.java
index e4007dc..45d6042 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/MoreCategoriesMenu.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/MoreCategoriesMenu.java
@@ -39,7 +39,7 @@ public final class MoreCategoriesMenu {
private static final int SLOT_PREVIOUS = 48;
private static final int SLOT_ADD = 49;
private static final int SLOT_REMOVE = 50;
- private static final int SLOT_NEXT = 53;
+ private static final int SLOT_NEXT = 50;
private static final String ACTION_BACK = "back";
private static final String ACTION_PREVIOUS = "previous";
private static final String ACTION_NEXT = "next";
@@ -86,7 +86,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @
return;
}
- List categories = plugin.customCategories().list();
+ List categories = plugin.customCategories().listVisible(plugin.adminModes().enabled(player)).stream().filter(category -> Permissions.canViewCategory(player, category.id())).toList();
int pages = pageCount(categories.size());
int page = clampPage(requestedPage, pages);
MoreCategoriesHolder holder = new MoreCategoriesHolder(mode, page);
@@ -97,6 +97,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @
renderEntries(plugin, inventory, categories, mode, page, plugin.adminModes().enabled(player));
renderControls(plugin, player, inventory, mode, page, pages);
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
private static void openEditor(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CategoryEditorHolder holder) {
@@ -113,6 +114,7 @@ private static void openEditor(@NotNull HeadDBPlugin plugin, @NotNull Player pla
inventory.setItem(plugin.guiConfig().slot("more.category-create.back", 45), actionItem(plugin, ACTION_BACK, "back"));
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) {
@@ -128,7 +130,10 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
}
Optional action = readAction(plugin, item);
- action.ifPresent(value -> handleEditorAction(plugin, player, editor, value));
+ action.ifPresent(value -> {
+ plugin.sounds().playGuiAction(player, value);
+ handleEditorAction(plugin, player, editor, value);
+ });
return true;
}
@@ -151,6 +156,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
return true;
}
+ plugin.sounds().playGuiAction(player, action.get());
+
handleAction(plugin, player, holder, event.getClick(), action.get());
return true;
}
@@ -175,6 +182,9 @@ private static void renderEntries(@NotNull HeadDBPlugin plugin, @NotNull Invento
item.editMeta(meta -> {
List lore = new ArrayList<>();
lore.add(GuiItems.idDetail("Heads", category.headIds().size()));
+ if (category.draft()) {
+ lore.add(GuiItems.idDetail("State", "DRAFT"));
+ }
lore.add(GuiItems.metaDetail("Icon", iconLabel(category)));
lore.add(Component.empty());
@@ -187,7 +197,7 @@ private static void renderEntries(@NotNull HeadDBPlugin plugin, @NotNull Invento
}
}
- meta.displayName(GuiItems.name(category.name(), mode == MoreCategoryMode.REMOVE ? NamedTextColor.RED : NamedTextColor.GOLD));
+ meta.displayName(GuiItems.name(category.draft() ? "DRAFT - " + category.name() : category.name(), category.draft() ? NamedTextColor.YELLOW : mode == MoreCategoryMode.REMOVE ? NamedTextColor.RED : NamedTextColor.GOLD));
meta.lore(lore);
});
return item;
@@ -241,7 +251,7 @@ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player p
return;
}
if (action.equals(ACTION_ADD)) {
- openEdit(plugin, player, "", "", "CHEST");
+ CreateCategoryMenu.open(plugin, player);
return;
}
if (action.equals(ACTION_REMOVE_MODE)) {
@@ -265,13 +275,12 @@ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player p
return;
}
CustomCategory category = existing.get();
- openEdit(plugin, player, category.id(), category.name(), category.material());
+ CreateCategoryMenu.openExisting(plugin, player, category.id());
return;
}
CustomCategory category = existing.get();
- Set ids = category.headIds().isEmpty() ? Set.of(new HeadId("remote:0")) : category.headIds();
- plugin.guis().openSearch(player, new SearchRequest("", ids, Set.of(), Set.of(), Set.of(), HeadSort.ID, SortDirection.ASCENDING, 1, 28, false));
+ CustomCategoryViewMenu.open(plugin, player, category.id(), 0);
return;
}
if (action.startsWith(ACTION_REMOVE)) {
@@ -379,6 +388,7 @@ private static void openConfirm(@NotNull HeadDBPlugin plugin, @NotNull Player pl
inventory.setItem(11, actionItem(plugin, ACTION_REMOVE + id, "confirm-yes"));
inventory.setItem(15, actionItem(plugin, ACTION_BACK, "confirm-no"));
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
private static boolean canAdminCategories(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
@@ -387,6 +397,7 @@ private static boolean canAdminCategories(@NotNull HeadDBPlugin plugin, @NotNull
private static void noPermission(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION);
}
private static @NotNull ItemStack editAction(@NotNull HeadDBPlugin plugin, @NotNull String action, @NotNull String iconKey, @NotNull String label, @NotNull String value) {
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/TagsMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/TagsMenu.java
new file mode 100644
index 0000000..b98db8b
--- /dev/null
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/category/TagsMenu.java
@@ -0,0 +1,388 @@
+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.local.taxonomy.CustomTaxonomyEntry;
+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.paper.gui.search.SearchMenuState;
+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.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+public final class TagsMenu {
+
+ 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_CREATE = 27;
+ private static final int SLOT_FILTER = 52;
+ private static final int SLOT_SEARCH = 53;
+ 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_CREATE = "create";
+ private static final String ACTION_FILTER = "filter";
+ private static final String ACTION_SEARCH = "search";
+ private static final String ACTION_ENTRY = "tag:";
+ 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 TagsMenu() {
+ throw new UnsupportedOperationException("This class cannot be instantiated.");
+ }
+
+ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ open(plugin, player, 0, false, "");
+ }
+
+ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, int requestedPage, boolean customOnly, @NotNull String query) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(query, "query");
+
+ if (!Permissions.has(player, Permissions.GUI_TAGS)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ boolean adminMode = plugin.adminModes().enabled(player);
+ Set customIds = customIds(plugin);
+ Map counts = counts(visibleHeads(plugin, player));
+ List entries = plugin.headRegistry().tags().stream()
+ .filter(entry -> adminMode || counts.containsKey(entry.id()))
+ .filter(entry -> !customOnly || customIds.contains(entry.id()))
+ .filter(entry -> matches(entry.id(), entry.name(), query))
+ .sorted(Comparator.comparing(HeadTag::name, String.CASE_INSENSITIVE_ORDER))
+ .toList();
+ int pages = pageCount(entries.size());
+ int page = Math.max(0, Math.min(requestedPage, pages - 1));
+ Holder holder = new Holder(page, customOnly, query.trim());
+ Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(title(plugin, page, pages, customOnly, query), adminMode));
+ holder.inventory(inventory);
+
+ fillBorder(plugin, inventory);
+ renderEntries(plugin, player, inventory, entries, counts, customIds, page);
+ renderControls(plugin, player, inventory, entries.size(), page, pages, customOnly, query);
+ player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
+ }
+
+ 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() 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;
+ }
+
+ plugin.sounds().playGuiAction(player, action.get());
+
+ handleAction(plugin, player, holder, action.get());
+ return true;
+ }
+
+ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Holder holder, @NotNull String action) {
+ if (action.equals(ACTION_BACK)) {
+ plugin.guis().openBrowse(player);
+ return;
+ }
+
+ if (action.equals(ACTION_PREVIOUS)) {
+ open(plugin, player, holder.page() - 1, holder.customOnly(), holder.query());
+ return;
+ }
+
+ if (action.equals(ACTION_NEXT)) {
+ open(plugin, player, holder.page() + 1, holder.customOnly(), holder.query());
+ return;
+ }
+
+ if (action.equals(ACTION_CREATE)) {
+ if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_CREATE_TAG)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ CreateTaxonomyMenu.openTag(plugin, player);
+ return;
+ }
+
+ if (action.equals(ACTION_FILTER)) {
+ if (!plugin.adminModes().enabled(player)) {
+ return;
+ }
+
+ open(plugin, player, 0, !holder.customOnly(), holder.query());
+ return;
+ }
+
+ if (action.equals(ACTION_SEARCH)) {
+ player.closeInventory();
+ player.getScheduler().run(plugin, task -> plugin.prompts().request(player, Component.text("Enter tag search text.", NamedTextColor.GOLD), value -> open(plugin, player, 0, holder.customOnly(), value), () -> open(plugin, player, holder.page(), holder.customOnly(), holder.query())), () -> {});
+ return;
+ }
+
+ if (!action.startsWith(ACTION_ENTRY)) {
+ return;
+ }
+
+ String id = action.substring(ACTION_ENTRY.length());
+ plugin.guis().openSearch(player, new SearchRequest("", Set.of(), Set.of(), Set.of(id), Set.of(), HeadSort.NAME, SortDirection.ASCENDING, 1, 28, false), SearchMenuState.BackTarget.TAGS);
+ }
+
+ private static void renderEntries(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull List entries, @NotNull Map counts, @NotNull Set customIds, int page) {
+ 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++) {
+ HeadTag entry = entries.get(index);
+ ItemStack item = entryItem(plugin, player, entry, counts.getOrDefault(entry.id(), 0), customIds.contains(entry.id()));
+ stamp(plugin, item, ACTION_ENTRY + entry.id());
+ inventory.setItem(ENTRY_SLOTS[slotIndex], item);
+ slotIndex++;
+ }
+ }
+
+ private static @NotNull ItemStack entryItem(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadTag entry, int heads, boolean custom) {
+ ItemStack item = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("tags"));
+ item.editMeta(meta -> {
+ java.util.List lore = new java.util.ArrayList<>();
+ if (entry.description() != null && !entry.description().isBlank()) {
+ lore.add(GuiItems.lore(entry.description(), NamedTextColor.GRAY));
+ lore.add(Component.empty());
+ }
+ if (plugin.adminModes().enabled(player)) {
+ lore.add(GuiItems.idDetail("Heads", heads));
+ lore.add(GuiItems.idDetail("ID", entry.id()));
+ lore.add(GuiItems.idDetail("Type", custom ? "Custom" : "Remote"));
+ lore.add(Component.empty());
+ }
+ lore.add(GuiItems.lore("Click to browse this tag.", NamedTextColor.GREEN));
+ meta.displayName(GuiItems.name(entry.name(), custom ? NamedTextColor.YELLOW : NamedTextColor.GOLD));
+ meta.lore(lore);
+ });
+ return item;
+ }
+
+ private static @NotNull List visibleHeads(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ boolean includeHidden = plugin.adminModes().enabled(player);
+ return plugin.headRegistry().heads(includeHidden).stream().filter(head -> Permissions.canViewCategory(player, head.category())).toList();
+ }
+
+ private static @NotNull Map counts(@NotNull List heads) {
+ Map counts = new HashMap<>();
+ for (Head head : heads) {
+ for (String value : head.tags()) {
+ counts.merge(value, 1, Integer::sum);
+ }
+ }
+ return Map.copyOf(counts);
+ }
+
+ private static @NotNull Set customIds(@NotNull HeadDBPlugin plugin) {
+ Set ids = new HashSet<>();
+ for (CustomTaxonomyEntry entry : plugin.customTags().list()) {
+ ids.add(entry.id());
+ }
+ return Set.copyOf(ids);
+ }
+
+ private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, int entries, int page, int pages, boolean customOnly, @NotNull String query) {
+ 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("Tags", entries),
+ GuiItems.idDetail("Page", (page + 1) + " / " + Math.max(1, pages)),
+ GuiItems.idDetail("Filter", customOnly ? "Custom" : "All"),
+ GuiItems.idDetail("Search", query.isBlank() ? "None" : query)
+ )));
+
+ if (page + 1 < pages) {
+ ItemStack next = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("next"));
+ stamp(plugin, next, ACTION_NEXT);
+ inventory.setItem(SLOT_NEXT, next);
+ }
+
+ if (plugin.adminModes().enabled(player)) {
+ ItemStack filter = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon(customOnly ? "taxonomy-filter-custom" : "taxonomy-filter-all"));
+ stamp(plugin, filter, ACTION_FILTER);
+ inventory.setItem(plugin.guiConfig().slot("tags.filter", SLOT_FILTER), filter);
+ }
+
+ ItemStack search = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("taxonomy-search"));
+ stamp(plugin, search, ACTION_SEARCH);
+ inventory.setItem(plugin.guiConfig().slot("tags.search", SLOT_SEARCH), search);
+
+ if (plugin.adminModes().enabled(player) && Permissions.has(player, Permissions.GUI_CREATE_TAG)) {
+ ItemStack create = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("create-tag"));
+ stamp(plugin, create, ACTION_CREATE);
+ inventory.setItem(plugin.guiConfig().slot("tags.create", SLOT_CREATE), create);
+ }
+ }
+
+ private static boolean matches(@NotNull String id, @NotNull String name, @NotNull String query) {
+ String trimmed = query.trim().toLowerCase(Locale.ROOT);
+ if (trimmed.isBlank()) {
+ return true;
+ }
+
+ String haystack = id.toLowerCase(Locale.ROOT) + " " + name.toLowerCase(Locale.ROOT);
+ for (String part : trimmed.split("\\s+")) {
+ if (part.isBlank()) {
+ continue;
+ }
+ if (!haystack.contains(part)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ 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 = new ItemStack(material);
+ filler.editMeta(meta -> {
+ meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name()));
+ meta.lore(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 @NotNull String title(@NotNull HeadDBPlugin plugin, int page, int pages, boolean customOnly, @NotNull String query) {
+ String key = customOnly ? "title.tags-custom-page" : "title.tags-page";
+ String fallback = customOnly ? "Custom Tags %page%/%pages%" : "Tags %page%/%pages%";
+ return plugin.guiConfig().text(key, fallback)
+ .replace("%page%", String.valueOf(page + 1))
+ .replace("%pages%", String.valueOf(Math.max(1, pages)))
+ .replace("%query%", query.trim());
+ }
+
+ private static int pageCount(int entries) {
+ if (entries <= 0) {
+ return 1;
+ }
+ return (int) Math.ceil((double) entries / (double) ENTRY_SLOTS.length);
+ }
+
+ 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, "tags_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 static final class Holder implements InventoryHolder {
+ private final int page;
+ private final boolean customOnly;
+ private final String query;
+ private Inventory inventory;
+
+ private Holder(int page, boolean customOnly, @NotNull String query) {
+ this.page = page;
+ this.customOnly = customOnly;
+ this.query = Objects.requireNonNull(query, "query");
+ }
+
+ private int page() { return page; }
+ private boolean customOnly() { return customOnly; }
+ private @NotNull String query() { return query; }
+
+ private void inventory(@NotNull Inventory inventory) {
+ this.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/common/GuiHeadIcons.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiHeadIcons.java
index 1d37a1a..ae60009 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiHeadIcons.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiHeadIcons.java
@@ -13,7 +13,9 @@
import io.github.silentdevelopment.headdb.paper.gui.config.GuiButtonEditorMenu;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextDecoration;
+import org.bukkit.Bukkit;
import org.bukkit.Material;
+import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
@@ -53,6 +55,7 @@ private GuiHeadIcons() {
return;
}
+ play(plugin, viewerId, icon.key());
action.accept(context);
});
}
@@ -82,6 +85,7 @@ private GuiHeadIcons() {
return;
}
+ play(plugin, viewerId, icon.key());
action.accept(context);
});
}
@@ -101,6 +105,7 @@ private GuiHeadIcons() {
return;
}
+ play(plugin, viewerId, icon.key());
action.accept(context);
});
}
@@ -153,6 +158,23 @@ private GuiHeadIcons() {
return Optional.empty();
}
+ private static void play(@NotNull HeadDBPlugin plugin, @NotNull Optional viewerId, @NotNull String iconKey) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(viewerId, "viewerId");
+ Objects.requireNonNull(iconKey, "iconKey");
+
+ if (viewerId.isEmpty()) {
+ return;
+ }
+
+ Player player = Bukkit.getPlayer(viewerId.get());
+ if (player == null) {
+ return;
+ }
+
+ plugin.sounds().playGuiIcon(player, iconKey);
+ }
+
private static ItemStack configuredHead(@NotNull HeadDBPlugin plugin, @NotNull GuiIconConfig icon) {
if (icon.headId().isBlank()) {
plugin.getSLF4JLogger().warn(
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiItems.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiItems.java
index 15a4b16..2197840 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiItems.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiItems.java
@@ -144,7 +144,7 @@ public static void fillEmpty(@NotNull HeadDBPlugin plugin, @NotNull PaperPag
public static @NotNull Component metaDetail(@NotNull String key, @NotNull Object value) {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(value, "value");
- return detail(key, value, NamedTextColor.DARK_GRAY);
+ return detail(key, value, NamedTextColor.GOLD);
}
public static @NotNull Component mini(@NotNull String value) {
@@ -160,7 +160,7 @@ public static void fillEmpty(@NotNull HeadDBPlugin plugin, @NotNull PaperPag
return mini(value);
}
- return Component.text(value, NamedTextColor.WHITE).decoration(TextDecoration.ITALIC, false);
+ return Component.text(value, NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false);
}
public static @NotNull List miniLore(@NotNull List lines) {
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiLabels.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiLabels.java
new file mode 100644
index 0000000..ac10fe4
--- /dev/null
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiLabels.java
@@ -0,0 +1,80 @@
+package io.github.silentdevelopment.headdb.paper.gui.common;
+
+import io.github.silentdevelopment.headdb.model.Head;
+import io.github.silentdevelopment.headdb.model.HeadId;
+import io.github.silentdevelopment.headdb.model.HeadCategory;
+import io.github.silentdevelopment.headdb.paper.HeadDBPlugin;
+import io.github.silentdevelopment.headdb.paper.gui.category.CustomCategory;
+import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Objects;
+
+public final class GuiLabels {
+
+ private GuiLabels() {
+ throw new UnsupportedOperationException("This class cannot be instantiated.");
+ }
+
+ public static @NotNull String head(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(id, "id");
+
+ return plugin.headRegistry().find(id).map(head -> head(plugin, player, head)).orElse(id.display());
+ }
+
+ public static @NotNull String head(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Head head) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(head, "head");
+
+ if (adminLabel(plugin, player)) {
+ return head.name() + " (" + head.id().display() + ")";
+ }
+
+ return head.name();
+ }
+
+ public static @NotNull String category(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull String id) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(id, "id");
+
+ return plugin.customCategories().find(id)
+ .map(category -> category(plugin, player, category))
+ .or(() -> plugin.headRegistry().category(id).map(category -> category(plugin, player, category)))
+ .orElse(id);
+ }
+
+ public static @NotNull String category(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadCategory category) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(category, "category");
+
+ if (adminLabel(plugin, player)) {
+ return category.name() + " (" + category.id() + ")";
+ }
+
+ return category.name();
+ }
+
+ public static @NotNull String category(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull CustomCategory category) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(category, "category");
+
+ if (adminLabel(plugin, player)) {
+ return category.name() + " (" + category.id() + ")";
+ }
+
+ return category.name();
+ }
+
+ public static boolean adminLabel(@NotNull HeadDBPlugin plugin, @NotNull Player player) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ return plugin.adminModes().enabled(player) || Permissions.has(player, Permissions.GUI_EDIT) || Permissions.has(player, Permissions.GUI_CUSTOM_CATEGORIES_ADMIN);
+ }
+}
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiTitles.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiTitles.java
index d1a3e1b..b8ff317 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiTitles.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/common/GuiTitles.java
@@ -9,8 +9,6 @@
public final class GuiTitles {
- private static final int ADMIN_TITLE_PIXEL_TARGET = 134;
- private static final int SPACE_WIDTH = 4;
private GuiTitles() {
throw new UnsupportedOperationException("This class cannot be instantiated.");
@@ -19,16 +17,7 @@ private GuiTitles() {
public static @NotNull Component title(@NotNull String title, boolean adminMode) {
Objects.requireNonNull(title, "title");
- String clean = title.trim();
- if (!adminMode) {
- return Component.text(clean, NamedTextColor.RED).decoration(TextDecoration.ITALIC, false);
- }
-
- int spaces = Math.max(2, (int) Math.ceil((double) (ADMIN_TITLE_PIXEL_TARGET - pixelWidth(clean) - pixelWidth("ADMIN")) / (double) SPACE_WIDTH));
- return Component.text(clean, NamedTextColor.RED)
- .append(Component.text(" ".repeat(spaces)))
- .append(Component.text("ADMIN", NamedTextColor.DARK_RED, TextDecoration.BOLD))
- .decoration(TextDecoration.ITALIC, false);
+ return Component.text(title.trim(), NamedTextColor.RED).decoration(TextDecoration.ITALIC, false);
}
public static @NotNull Component title(@NotNull String prefix, @NotNull String detail, boolean adminMode) {
@@ -39,24 +28,6 @@ private GuiTitles() {
return title(clean, adminMode);
}
- private static int pixelWidth(@NotNull String value) {
- int width = 0;
- for (int index = 0; index < value.length(); index++) {
- width += characterWidth(value.charAt(index));
- }
- return width;
- }
-
- private static int characterWidth(char character) {
- return switch (character) {
- case ' ', '!', ',', '.', ':', ';', '|', 'i', 'l', '\'' -> 2;
- case '[', ']', 'I', 't' -> 4;
- case '"', '(', ')', '*', '<', '>', 'f', 'k' -> 5;
- case '@', '~' -> 7;
- default -> 6;
- };
- }
-
private static @NotNull String shortTitle(@NotNull String value) {
if (value.length() <= 16) {
return value;
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonEditorMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonEditorMenu.java
index 228b1cf..efbfa41 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonEditorMenu.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonEditorMenu.java
@@ -94,6 +94,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @N
inventory.setItem(plugin.guiConfig().slot("gui-button.back", 18), action(plugin, ACTION_BACK, "back"));
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) {
@@ -120,6 +121,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
return true;
}
+ plugin.sounds().playGuiAction(player, action.get());
+
handleAction(plugin, player, holder.key(), action.get());
return true;
}
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonLoreEditorMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonLoreEditorMenu.java
index 4e2233f..47ef884 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonLoreEditorMenu.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/config/GuiButtonLoreEditorMenu.java
@@ -66,6 +66,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @
renderControls(plugin, inventory, buttonKey, page, pages);
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) {
@@ -88,6 +89,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
return true;
}
+ plugin.sounds().playGuiAction(player, action.get());
+
handleAction(plugin, player, holder, event.getClick(), action.get());
return true;
}
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 a43643a..024664b 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
@@ -25,18 +25,47 @@ public final class GuiConfig {
"refresh",
"reload",
"close",
+ "browse",
"browse-all",
"search",
"player-heads",
"custom-heads",
"hidden-heads",
"favorites",
+ "collections",
+ "tags",
+ "create-tag",
+ "create-collection",
+ "create-category",
+ "taxonomy-id",
+ "taxonomy-name",
+ "taxonomy-description",
+ "taxonomy-save",
+ "taxonomy-filter-all",
+ "taxonomy-filter-custom",
+ "taxonomy-search",
+ "create-head",
+ "create-head-held",
+ "create-head-fresh",
+ "create-head-id",
+ "create-head-name",
+ "create-head-texture",
+ "create-head-category",
+ "create-head-tags",
+ "create-head-collections",
+ "create-head-save",
"more-categories",
"more-category",
"category",
"category-add",
"category-remove",
"category-name",
+ "category-description",
+ "category-permission",
+ "category-save-draft",
+ "category-publish",
+ "category-delete",
+ "category-price",
"category-id",
"category-material",
"category-head-icon",
@@ -59,6 +88,8 @@ public final class GuiConfig {
"edit-visibility-visible",
"edit-visibility-hidden",
"edit-delete",
+ "edit-publish",
+ "edit-price",
"edit-reset",
"lore-add",
"lore-clear",
@@ -79,6 +110,7 @@ public final class GuiConfig {
"filter-category",
"filter-tags",
"filter-collections",
+ "filter-ids",
"filter-selected",
"filter-unselected",
"clear-filters",
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/DeleteHeadConfirmMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/DeleteHeadConfirmMenu.java
new file mode 100644
index 0000000..8ca2898
--- /dev/null
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/DeleteHeadConfirmMenu.java
@@ -0,0 +1,204 @@
+package io.github.silentdevelopment.headdb.paper.gui.edit;
+
+import io.github.silentdevelopment.headdb.model.Head;
+import io.github.silentdevelopment.headdb.model.HeadId;
+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.GuiLabels;
+import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials;
+import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles;
+import io.github.silentdevelopment.headdb.paper.message.MessageKey;
+import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
+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.Objects;
+import java.util.Optional;
+
+public final class DeleteHeadConfirmMenu {
+
+ private static final int SIZE = 27;
+ private static final int SLOT_PREVIEW = 4;
+ private static final int SLOT_CONFIRM = 11;
+ private static final int SLOT_CANCEL = 15;
+ private static final String ACTION_CONFIRM = "confirm";
+ private static final String ACTION_CANCEL = "cancel";
+
+ private DeleteHeadConfirmMenu() {
+ throw new UnsupportedOperationException("This class cannot be instantiated.");
+ }
+
+ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id) {
+ Objects.requireNonNull(plugin, "plugin");
+ Objects.requireNonNull(player, "player");
+ Objects.requireNonNull(id, "id");
+
+ if (!id.isCustom() || !Permissions.has(player, Permissions.CUSTOM_DELETE)) {
+ noPermission(plugin, player);
+ return;
+ }
+
+ Optional head = plugin.headRegistry().find(id);
+ if (head.isEmpty()) {
+ player.sendMessage(Component.text("Custom head no longer exists.", NamedTextColor.RED));
+ return;
+ }
+
+ ConfirmHolder holder = new ConfirmHolder(id);
+ Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title("Delete Head", true));
+ holder.inventory(inventory);
+ fill(plugin, inventory);
+
+ ItemStack preview = plugin.itemFactory().create(head.get());
+ preview.editMeta(meta -> {
+ java.util.List lore = meta.lore() == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(meta.lore());
+ lore.add(Component.empty());
+ lore.add(GuiItems.idDetail("ID", id.display()));
+ lore.add(GuiItems.metaDetail("Name", head.get().name()));
+ lore.add(GuiItems.lore("This action cannot be undone.", NamedTextColor.RED));
+ meta.lore(lore);
+ });
+ inventory.setItem(SLOT_PREVIEW, preview);
+ ItemStack confirm = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("confirm-yes"));
+ stamp(plugin, confirm, ACTION_CONFIRM);
+ inventory.setItem(SLOT_CONFIRM, confirm);
+
+ ItemStack cancel = GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("confirm-no"));
+ stamp(plugin, cancel, ACTION_CANCEL);
+ inventory.setItem(SLOT_CANCEL, cancel);
+
+ player.openInventory(inventory);
+ plugin.sounds().play(player, SoundKey.MENU_OPEN);
+ }
+
+ 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() instanceof ConfirmHolder 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;
+ }
+
+ if (action.get().equals(ACTION_CANCEL)) {
+ plugin.sounds().play(player, SoundKey.CANCEL);
+ HeadEditMenu.open(plugin, player, holder.id());
+ return true;
+ }
+
+ if (!action.get().equals(ACTION_CONFIRM)) {
+ return true;
+ }
+
+ if (!Permissions.has(player, Permissions.CUSTOM_DELETE)) {
+ noPermission(plugin, player);
+ return true;
+ }
+
+ String label = GuiLabels.head(plugin, player, holder.id());
+ if (!plugin.headRegistry().customHeads().delete(holder.id())) {
+ player.closeInventory();
+ player.sendMessage(Component.text("Custom head no longer exists: ", NamedTextColor.RED).append(Component.text(holder.id().display(), NamedTextColor.GOLD)));
+ plugin.sounds().play(player, SoundKey.INVALID);
+ return true;
+ }
+
+ plugin.headRegistry().onLocalMutation();
+ plugin.clearItemCache();
+ plugin.clearSearchCache();
+ player.closeInventory();
+ player.sendMessage(Component.text("Custom head deleted: ", NamedTextColor.GRAY).append(Component.text(label, NamedTextColor.GOLD)));
+ plugin.sounds().play(player, SoundKey.DELETE_CONFIRM);
+ return true;
+ }
+
+ private static void fill(@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 = new ItemStack(material);
+ filler.editMeta(meta -> {
+ meta.displayName(GuiItems.mini(plugin.guiConfig().filler().name()));
+ meta.lore(GuiItems.miniLore(plugin.guiConfig().filler().lore()));
+ });
+
+ for (int slot = 0; slot < SIZE; slot++) {
+ 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, "delete_head_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, SoundKey.NO_PERMISSION);
+ }
+
+ private static final class ConfirmHolder implements InventoryHolder {
+ private final HeadId id;
+ private Inventory inventory;
+
+ private ConfirmHolder(@NotNull HeadId id) {
+ this.id = id;
+ }
+
+ private @NotNull HeadId id() {
+ return id;
+ }
+
+ private void inventory(@NotNull Inventory inventory) {
+ this.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/edit/HeadEditListener.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditListener.java
index 4b97897..fb0185e 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
@@ -1,13 +1,23 @@
package io.github.silentdevelopment.headdb.paper.gui.edit;
import io.github.silentdevelopment.headdb.paper.gui.common.GuiMaterials;
+import io.github.silentdevelopment.headdb.paper.gui.common.GuiLabels;
import io.github.silentdevelopment.headdb.model.Head;
import io.github.silentdevelopment.headdb.model.HeadId;
import io.github.silentdevelopment.headdb.paper.HeadDBPlugin;
import io.github.silentdevelopment.headdb.paper.gui.hidden.HiddenHeadsMenu;
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.TagsMenu;
+import io.github.silentdevelopment.headdb.paper.gui.category.CreateHeadMenu;
+import io.github.silentdevelopment.headdb.paper.gui.category.CreateCategoryMenu;
+import io.github.silentdevelopment.headdb.paper.gui.category.CreateTaxonomyMenu;
+import io.github.silentdevelopment.headdb.paper.gui.category.DeleteCategoryConfirmMenu;
+import io.github.silentdevelopment.headdb.paper.gui.category.CustomCategoryViewMenu;
import io.github.silentdevelopment.headdb.paper.gui.category.CategoryMembersMenu;
import io.github.silentdevelopment.headdb.paper.gui.category.CategoryHeadPickerMenu;
+import io.github.silentdevelopment.headdb.paper.gui.settings.CommandConfirmMenu;
import io.github.silentdevelopment.headdb.paper.gui.favorites.FavoritesMenu;
import io.github.silentdevelopment.headdb.paper.gui.favorites.FavoriteClickGuard;
import io.github.silentdevelopment.headdb.paper.gui.config.GuiButtonEditorMenu;
@@ -17,6 +27,7 @@
import io.github.silentdevelopment.headdb.paper.local.custom.StoredCustomHead;
import io.github.silentdevelopment.headdb.paper.local.override.RemoteHeadOverride;
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
+import io.github.silentdevelopment.headdb.paper.sound.SoundKey;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
@@ -27,6 +38,8 @@
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.event.inventory.InventoryCloseEvent;
+import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;
@@ -38,6 +51,8 @@
import java.util.Optional;
import java.util.Set;
+import static io.github.silentdevelopment.headdb.paper.gui.favorites.FavoritesMenu.give;
+
public final class HeadEditListener implements Listener {
private final HeadDBPlugin plugin;
@@ -46,6 +61,22 @@ public HeadEditListener(@NotNull HeadDBPlugin plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin");
}
+
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void onInventoryClose(@NotNull InventoryCloseEvent event) {
+ if (!(event.getPlayer() instanceof Player player)) {
+ return;
+ }
+
+ InventoryHolder holder = event.getInventory().getHolder();
+ if (holder == null) {
+ return;
+ }
+
+ CreateHeadMenu.handleClose(plugin, player, holder);
+ CreateCategoryMenu.handleClose(plugin, player, holder);
+ }
+
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
public void onInventoryClick(@NotNull InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) {
@@ -78,6 +109,50 @@ public void onInventoryClick(@NotNull InventoryClickEvent event) {
}
}
+ if (BrowseMenu.handleClick(plugin, player, event)) {
+ return;
+ }
+
+ if (CollectionsMenu.handleClick(plugin, player, event)) {
+ return;
+ }
+
+ if (TagsMenu.handleClick(plugin, player, event)) {
+ return;
+ }
+
+ if (CommandConfirmMenu.handleClick(plugin, player, event)) {
+ return;
+ }
+
+ if (CreateHeadMenu.handleClick(plugin, player, event)) {
+ return;
+ }
+
+ if (CreateCategoryMenu.handleClick(plugin, player, event)) {
+ return;
+ }
+
+ if (CreateTaxonomyMenu.handleClick(plugin, player, event)) {
+ return;
+ }
+
+ if (CustomCategoryViewMenu.handleClick(plugin, player, event, id -> openEdit(player, id), item -> giveListedHead(player, item))) {
+ return;
+ }
+
+ if (DeleteCategoryConfirmMenu.handleClick(plugin, player, event)) {
+ return;
+ }
+
+ if (DeleteHeadConfirmMenu.handleClick(plugin, player, event)) {
+ return;
+ }
+
+ if (handleAdvancedSearchClick(player, event)) {
+ return;
+ }
+
if (HiddenHeadsMenu.handleClick(plugin, player, event)) {
return;
}
@@ -147,6 +222,30 @@ public void onInventoryClick(@NotNull InventoryClickEvent event) {
}
}
+ private boolean handleAdvancedSearchClick(@NotNull Player player, @NotNull InventoryClickEvent event) {
+ if (event.getClickedInventory() == null || !event.getClickedInventory().equals(event.getView().getTopInventory())) {
+ return false;
+ }
+
+ if (event.getClick() != ClickType.RIGHT && event.getClick() != ClickType.SHIFT_RIGHT) {
+ return false;
+ }
+
+ ItemStack item = event.getCurrentItem();
+ if (item == null || GuiMaterials.isAir(item.getType())) {
+ return false;
+ }
+
+ Optional iconKey = GuiButtonEditorMenu.readIconKey(plugin, item);
+ if (iconKey.isEmpty() || !iconKey.get().equals("search")) {
+ return false;
+ }
+
+ deny(event);
+ plugin.guis().openAdvancedSearch(player);
+ return true;
+ }
+
private void toggleFavorite(@NotNull Player player, @NotNull HeadId id) {
if (!Permissions.has(player, Permissions.FAVORITES_TOGGLE)) {
@@ -155,7 +254,8 @@ private void toggleFavorite(@NotNull Player player, @NotNull HeadId id) {
}
boolean added = plugin.favorites().toggle(player.getUniqueId(), id);
- player.sendMessage(Component.text(added ? "Added favorite: " : "Removed favorite: ", added ? NamedTextColor.YELLOW : NamedTextColor.GRAY).append(Component.text(id.display(), NamedTextColor.GOLD)));
+ player.sendMessage(Component.text(added ? "Added favorite: " : "Removed favorite: ", added ? NamedTextColor.YELLOW : NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id), NamedTextColor.GOLD)));
+ plugin.sounds().play(player, added ? SoundKey.FAVORITE_ADD : SoundKey.FAVORITE_REMOVE);
}
private void giveListedHead(@NotNull Player player, @NotNull ItemStack item) {
@@ -173,11 +273,13 @@ private void giveListedHead(@NotNull Player player, @NotNull ItemStack item) {
Optional head = plugin.headRegistry().find(id);
if (head.isEmpty()) {
player.sendMessage(Component.text("Head no longer exists.", NamedTextColor.RED));
+ plugin.sounds().play(player, SoundKey.INVALID);
return;
}
if (player.getInventory().firstEmpty() == -1) {
player.sendMessage(Component.text("Your inventory is full.", NamedTextColor.RED));
+ plugin.sounds().play(player, SoundKey.INVALID);
return;
}
@@ -188,7 +290,11 @@ private void giveListedHead(@NotNull Player player, @NotNull ItemStack item) {
java.util.Map remaining = player.getInventory().addItem(item.clone());
if (!remaining.isEmpty()) {
player.sendMessage(Component.text("Your inventory is full.", NamedTextColor.RED));
+ plugin.sounds().play(player, SoundKey.INVALID);
+ return;
}
+
+ plugin.sounds().play(player, SoundKey.TAKE_HEAD);
}
private @NotNull Optional dropTarget(@NotNull InventoryClickEvent event, @NotNull ItemStack item) {
@@ -202,6 +308,7 @@ private void giveListedHead(@NotNull Player player, @NotNull ItemStack item) {
private void openEdit(@NotNull Player player, @NotNull HeadId id) {
if (!plugin.adminModes().enabled(player)) {
player.sendMessage(Component.text("Enable Admin Mode to edit heads.", NamedTextColor.RED));
+ plugin.sounds().play(player, SoundKey.NO_PERMISSION);
return;
}
@@ -244,6 +351,7 @@ private void handleAction(@NotNull Player player, @NotNull InventoryClickEvent e
if (!plugin.adminModes().enabled(player)) {
player.sendMessage(Component.text("Enable Admin Mode to edit heads.", NamedTextColor.RED));
+ plugin.sounds().play(player, SoundKey.NO_PERMISSION);
return;
}
@@ -283,7 +391,17 @@ private void handleAction(@NotNull Player player, @NotNull InventoryClickEvent e
}
if (action.equals(HeadEditMenu.ACTION_DELETE)) {
- deleteCustom(player, id);
+ confirmDeleteCustom(player, id);
+ return;
+ }
+
+ if (action.equals(HeadEditMenu.ACTION_PUBLISH)) {
+ publishCustom(player, id);
+ return;
+ }
+
+ if (action.equals(HeadEditMenu.ACTION_PRICE)) {
+ promptPrice(player, id);
return;
}
@@ -358,6 +476,7 @@ private void promptName(@NotNull Player player, @NotNull HeadId id) {
}
mutated(player, id);
+ plugin.sounds().play(player, SoundKey.SAVE);
player.sendMessage(Component.text("Name Updated", NamedTextColor.GOLD, TextDecoration.BOLD));
player.sendMessage(Component.text(oldName, NamedTextColor.GRAY).append(Component.text(" > ", NamedTextColor.DARK_GRAY)).append(Component.text(value, NamedTextColor.GREEN)));
HeadEditMenu.open(plugin, player, id);
@@ -374,11 +493,12 @@ private void setCategory(@NotNull Player player, @NotNull HeadId id, @NotNull St
plugin.headRegistry().overrides().save(override.withCategory(category, player.getUniqueId()));
} else if (id.isCustom()) {
StoredCustomHead stored = plugin.headRegistry().customHeads().findStored(id).orElseThrow(() -> new IllegalArgumentException("Unknown custom head: " + id));
- plugin.headRegistry().customHeads().save(new StoredCustomHead(stored.id(), stored.name(), stored.textureHash(), stored.textureSignature(), stored.lore(), stored.tags(), stored.collections(), category, stored.createdAt(), Instant.now(), stored.createdBy()));
+ plugin.headRegistry().customHeads().save(stored.withCategory(category));
}
mutated(player, id);
- player.sendMessage(Component.text("Category set to ", NamedTextColor.GRAY).append(Component.text(category, NamedTextColor.GOLD)));
+ plugin.sounds().play(player, SoundKey.SAVE);
+ player.sendMessage(Component.text("Category set to ", NamedTextColor.GRAY).append(Component.text(GuiLabels.category(plugin, player, category), NamedTextColor.GOLD)));
HeadEditMenu.open(plugin, player, id);
}
@@ -394,10 +514,11 @@ private void toggleTag(@NotNull Player player, @NotNull HeadId id, @NotNull Stri
} else if (id.isCustom()) {
StoredCustomHead stored = plugin.headRegistry().customHeads().findStored(id).orElseThrow(() -> new IllegalArgumentException("Unknown custom head: " + id));
Set tags = toggle(stored.tags(), tag);
- plugin.headRegistry().customHeads().save(new StoredCustomHead(stored.id(), stored.name(), stored.textureHash(), stored.textureSignature(), stored.lore(), tags, stored.collections(), stored.category(), stored.createdAt(), Instant.now(), stored.createdBy()));
+ plugin.headRegistry().customHeads().save(stored.withTags(tags));
}
mutated(player, id);
+ plugin.sounds().play(player, SoundKey.TOGGLE);
HeadEditMenu.openTags(plugin, player, id, page);
}
@@ -413,10 +534,11 @@ private void toggleCollection(@NotNull Player player, @NotNull HeadId id, @NotNu
} else if (id.isCustom()) {
StoredCustomHead stored = plugin.headRegistry().customHeads().findStored(id).orElseThrow(() -> new IllegalArgumentException("Unknown custom head: " + id));
Set collections = toggle(stored.collections(), collection);
- plugin.headRegistry().customHeads().save(new StoredCustomHead(stored.id(), stored.name(), stored.textureHash(), stored.textureSignature(), stored.lore(), stored.tags(), collections, stored.category(), stored.createdAt(), Instant.now(), stored.createdBy()));
+ plugin.headRegistry().customHeads().save(stored.withCollections(collections));
}
mutated(player, id);
+ plugin.sounds().play(player, SoundKey.TOGGLE);
HeadEditMenu.openCollections(plugin, player, id, page);
}
@@ -430,6 +552,7 @@ private void toggleVisibility(@NotNull Player player, @NotNull HeadId id) {
RemoteHeadOverride override = plugin.headRegistry().overrides().find(id).orElse(RemoteHeadOverride.empty(id, player.getUniqueId()));
plugin.headRegistry().overrides().save(override.withHidden(!hidden, player.getUniqueId()));
mutated(player, id);
+ plugin.sounds().play(player, hidden ? SoundKey.SHOW_HEAD : SoundKey.HIDE_HEAD);
player.sendMessage(Component.text(hidden ? "Head is now visible." : "Head is now hidden.", hidden ? NamedTextColor.GREEN : NamedTextColor.GRAY));
HeadEditMenu.open(plugin, player, id);
}
@@ -442,20 +565,62 @@ private void reset(@NotNull Player player, @NotNull HeadId id) {
plugin.headRegistry().overrides().delete(id);
mutated(player, id);
+ plugin.sounds().play(player, SoundKey.SAVE);
player.sendMessage(Component.text("Local override reset.", NamedTextColor.GRAY));
HeadEditMenu.open(plugin, player, id);
}
- private void deleteCustom(@NotNull Player player, @NotNull HeadId id) {
+ private void confirmDeleteCustom(@NotNull Player player, @NotNull HeadId id) {
if (!id.isCustom() || !Permissions.has(player, Permissions.CUSTOM_DELETE)) {
noPermission(player);
return;
}
- plugin.headRegistry().customHeads().delete(id);
+ DeleteHeadConfirmMenu.open(plugin, player, id);
+ }
+
+
+ private void publishCustom(@NotNull Player player, @NotNull HeadId id) {
+ if (!id.isCustom() || !Permissions.has(player, Permissions.CUSTOM_CREATE)) {
+ noPermission(player);
+ return;
+ }
+
+ StoredCustomHead stored = plugin.headRegistry().customHeads().findStored(id).orElseThrow(() -> new IllegalArgumentException("Unknown custom head: " + id));
+ plugin.headRegistry().customHeads().save(stored.withDraft(false));
mutated(player, id);
+ plugin.sounds().play(player, SoundKey.PUBLISH);
+ player.sendMessage(Component.text("Draft published: ", NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id), NamedTextColor.GOLD)));
+ HeadEditMenu.open(plugin, player, id);
+ }
+
+ private void promptPrice(@NotNull Player player, @NotNull HeadId id) {
+ if (!plugin.economy().enabled()) {
+ HeadEditMenu.open(plugin, player, id);
+ return;
+ }
+
+ if (!editable(player, id, Permissions.EDIT)) {
+ return;
+ }
+
player.closeInventory();
- player.sendMessage(Component.text("Custom head deleted: ", NamedTextColor.GRAY).append(Component.text(id.display(), NamedTextColor.GOLD)));
+ plugin.prompts().request(player, Component.text("Enter the new head price, or 0 to clear.", NamedTextColor.GOLD), value -> {
+ try {
+ double price = Double.parseDouble(value.trim());
+ plugin.economy().setHeadPrice(id, price);
+ player.sendMessage(plugin.messages().priceUpdated(player, GuiLabels.head(plugin, player, id), plugin.economy().format(price)));
+ plugin.sounds().play(player, SoundKey.PRICE_SUCCESS);
+ } catch (NumberFormatException exception) {
+ player.sendMessage(plugin.messages().priceInvalid(player));
+ plugin.sounds().play(player, SoundKey.PRICE_FAILURE);
+ } catch (RuntimeException exception) {
+ player.sendMessage(Component.text("Failed to update head price: " + exception.getMessage(), NamedTextColor.RED));
+ plugin.sounds().play(player, SoundKey.PRICE_FAILURE);
+ }
+
+ HeadEditMenu.open(plugin, player, id);
+ }, () -> HeadEditMenu.open(plugin, player, id));
}
private void promptLoreAdd(@NotNull Player player, @NotNull HeadId id) {
@@ -515,10 +680,11 @@ private void setLore(@NotNull Player player, @NotNull HeadId id, @NotNull List new IllegalArgumentException("Unknown custom head: " + id));
- plugin.headRegistry().customHeads().save(new StoredCustomHead(stored.id(), stored.name(), stored.textureHash(), stored.textureSignature(), cleaned, stored.tags(), stored.collections(), stored.category(), stored.createdAt(), Instant.now(), stored.createdBy()));
+ plugin.headRegistry().customHeads().save(stored.withLore(cleaned));
}
mutated(player, id);
+ plugin.sounds().play(player, SoundKey.SAVE);
player.sendMessage(Component.text(message, NamedTextColor.GRAY));
HeadEditMenu.openLore(plugin, player, id, 0);
}
@@ -534,10 +700,11 @@ private void resetLore(@NotNull Player player, @NotNull HeadId id) {
plugin.headRegistry().overrides().save(override.withLore(null, player.getUniqueId()));
} else if (id.isCustom()) {
StoredCustomHead stored = plugin.headRegistry().customHeads().findStored(id).orElseThrow(() -> new IllegalArgumentException("Unknown custom head: " + id));
- plugin.headRegistry().customHeads().save(new StoredCustomHead(stored.id(), stored.name(), stored.textureHash(), stored.textureSignature(), List.of(), stored.tags(), stored.collections(), stored.category(), stored.createdAt(), Instant.now(), stored.createdBy()));
+ plugin.headRegistry().customHeads().save(stored.withLore(List.of()));
}
mutated(player, id);
+ plugin.sounds().play(player, SoundKey.SAVE);
player.sendMessage(Component.text("Lore reset to default.", NamedTextColor.GRAY));
HeadEditMenu.openLore(plugin, player, id, 0);
}
@@ -569,6 +736,7 @@ private void mutated(@NotNull Player player, @NotNull HeadId id) {
private void noPermission(@NotNull Player player) {
player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION);
}
private static @NotNull Set toggle(@NotNull Set values, @NotNull String value) {
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditMenu.java
index dc80963..6744735 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditMenu.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/edit/HeadEditMenu.java
@@ -10,7 +10,10 @@
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;
+import io.github.silentdevelopment.headdb.paper.gui.category.CustomCategory;
import io.github.silentdevelopment.headdb.paper.item.HeadItemIds;
+import io.github.silentdevelopment.headdb.paper.local.custom.StoredCustomHead;
+import io.github.silentdevelopment.headdb.paper.permission.Permissions;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
@@ -28,7 +31,9 @@
import java.util.ArrayList;
import java.util.Comparator;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@@ -46,6 +51,8 @@ public final class HeadEditMenu {
public static final String ACTION_VISIBILITY = "visibility";
public static final String ACTION_RESET = "reset";
public static final String ACTION_DELETE = "delete";
+ public static final String ACTION_PUBLISH = "publish";
+ public static final String ACTION_PRICE = "price";
public static final String ACTION_BACK_MAIN = "back-main";
public static final String ACTION_BACK_EDIT = "back-edit";
public static final String ACTION_PREVIOUS = "previous";
@@ -106,6 +113,7 @@ public static void openRoot(@NotNull HeadDBPlugin plugin, @NotNull Player player
GuiItems.lore("There are no local override options for player heads.", NamedTextColor.DARK_GRAY)
)), id, ACTION_PREVIEW));
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
return;
}
@@ -114,21 +122,38 @@ public static void openRoot(@NotNull HeadDBPlugin plugin, @NotNull Player player
inventory.setItem(plugin.guiConfig().slot("edit.category", 29), button(plugin, id, ACTION_CATEGORY_MENU, "edit-category"));
inventory.setItem(plugin.guiConfig().slot("edit.tags", 31), button(plugin, id, ACTION_TAGS_MENU, "edit-tags"));
inventory.setItem(plugin.guiConfig().slot("edit.collections", 33), button(plugin, id, ACTION_COLLECTIONS_MENU, "edit-collections"));
+ if (plugin.economy().enabled()) {
+ inventory.setItem(plugin.guiConfig().slot("edit.price", 35), button(plugin, id, ACTION_PRICE, "edit-price"));
+ }
if (id.isRemote()) {
inventory.setItem(plugin.guiConfig().slot("edit.visibility", 39), visibilityButton(plugin, id));
inventory.setItem(plugin.guiConfig().slot("edit.reset", 41), button(plugin, id, ACTION_RESET, "edit-reset"));
} else if (id.isCustom()) {
+ boolean draft = plugin.headRegistry().customHeads().findStored(id).map(StoredCustomHead::draft).orElse(false);
+ if (draft) {
+ inventory.setItem(plugin.guiConfig().slot("edit.publish", 39), button(plugin, id, ACTION_PUBLISH, "edit-publish"));
+ }
inventory.setItem(plugin.guiConfig().slot("edit.delete", 41), button(plugin, id, ACTION_DELETE, "edit-delete"));
}
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
public static void openCategories(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id, int page) {
- List entries = plugin.headRegistry().categories().stream().sorted(Comparator.comparing(HeadCategory::name, String.CASE_INSENSITIVE_ORDER)).map(category -> new Entry(category.id(), category.name())).toList();
+ Map entries = new LinkedHashMap<>();
+ plugin.headRegistry().categories().stream()
+ .filter(category -> Permissions.canViewCategory(player, category.id()))
+ .sorted(Comparator.comparing(HeadCategory::name, String.CASE_INSENSITIVE_ORDER))
+ .forEach(category -> entries.put(category.id(), new Entry(category.id(), category.name())));
+ boolean adminMode = plugin.adminModes().enabled(player);
+ plugin.customCategories().listVisible(adminMode).stream()
+ .filter(category -> Permissions.canViewCategory(player, category.id()))
+ .sorted(Comparator.comparing(CustomCategory::name, String.CASE_INSENSITIVE_ORDER))
+ .forEach(category -> entries.put(category.id(), new Entry(category.id(), category.draft() ? "DRAFT - " + category.name() : category.name())));
String current = previewHead(plugin, id).map(Head::category).orElse("unknown");
- openSelection(plugin, player, id, EditMenuType.CATEGORY, page, entries, "Category: " + current, ACTION_CATEGORY_SELECT, selectedCategory(plugin, id));
+ openSelection(plugin, player, id, EditMenuType.CATEGORY, page, List.copyOf(entries.values()), "Category: " + current, ACTION_CATEGORY_SELECT, selectedCategory(plugin, id));
}
public static void openTags(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id, int page) {
@@ -169,6 +194,7 @@ public static void openLore(@NotNull HeadDBPlugin plugin, @NotNull Player player
inventory.setItem(plugin.guiConfig().slot("edit.lore.clear", 50), button(plugin, id, ACTION_LORE_CLEAR, "lore-clear"));
inventory.setItem(plugin.guiConfig().slot("edit.lore.reset", 53), button(plugin, id, ACTION_LORE_RESET, "lore-reset"));
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
public static @NotNull Optional actionTarget(@NotNull HeadDBPlugin plugin, @NotNull ItemStack item) {
@@ -219,6 +245,7 @@ private static void openSelection(@NotNull HeadDBPlugin plugin, @NotNull Player
inventory.setItem(SLOT_PREVIOUS, safePage > 0 ? button(plugin, id, ACTION_PREVIOUS, "previous") : inventory.getItem(SLOT_PREVIOUS));
inventory.setItem(SLOT_NEXT, safePage + 1 < pages ? button(plugin, id, ACTION_NEXT, "next") : inventory.getItem(SLOT_NEXT));
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
private static @NotNull Optional previewHead(@NotNull HeadDBPlugin plugin, @NotNull HeadId id) {
@@ -226,6 +253,10 @@ private static void openSelection(@NotNull HeadDBPlugin plugin, @NotNull Player
return Optional.of(playerHead(id));
}
+ if (id.isCustom()) {
+ return plugin.headRegistry().customHeads().findStored(id).map(StoredCustomHead::toHead);
+ }
+
return plugin.headRegistry().find(id);
}
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoriteHeadService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoriteHeadService.java
index a225b67..1dac063 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoriteHeadService.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoriteHeadService.java
@@ -14,7 +14,10 @@
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.LinkedHashSet;
+import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
@@ -24,61 +27,48 @@ public final class FavoriteHeadService {
private static final Component STAR = Component.text(" ★", NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false);
private final SQLiteDataSource dataSource;
+ private final Map> cache;
public FavoriteHeadService(@NotNull Path databaseFile) {
Objects.requireNonNull(databaseFile, "databaseFile");
this.dataSource = dataSource(databaseFile);
+ this.cache = new HashMap<>();
createSchema();
}
public synchronized boolean isFavorite(@NotNull UUID playerId, @NotNull HeadId headId) {
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(headId, "headId");
-
- try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT 1 FROM headdb_favorites WHERE player_id = ? AND head_id = ?")) {
- statement.setString(1, playerId.toString());
- statement.setString(2, headId.toString());
- try (ResultSet result = statement.executeQuery()) {
- return result.next();
- }
- } catch (SQLException exception) {
- throw new IllegalStateException("Failed to read HeadDB favorite.", exception);
- }
+ return favorites(playerId).contains(headId);
}
public synchronized boolean toggle(@NotNull UUID playerId, @NotNull HeadId headId) {
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(headId, "headId");
- if (isFavorite(playerId, headId)) {
+ Set favorites = new LinkedHashSet<>(favorites(playerId));
+ if (favorites.contains(headId)) {
remove(playerId, headId);
+ favorites.remove(headId);
+ cache.put(playerId, immutable(favorites));
return false;
}
add(playerId, headId);
+ favorites.add(headId);
+ cache.put(playerId, immutable(favorites));
return true;
}
public synchronized @NotNull Set favorites(@NotNull UUID playerId) {
Objects.requireNonNull(playerId, "playerId");
-
- LinkedHashSet ids = new LinkedHashSet<>();
- try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT head_id FROM headdb_favorites WHERE player_id = ? ORDER BY created_at ASC")) {
- statement.setString(1, playerId.toString());
- try (ResultSet result = statement.executeQuery()) {
- while (result.next()) {
- try {
- ids.add(new HeadId(result.getString("head_id")));
- } catch (IllegalArgumentException ignored) {
- // Ignore stale malformed rows.
- }
- }
- }
- } catch (SQLException exception) {
- throw new IllegalStateException("Failed to list HeadDB favorites.", exception);
+ Set cached = cache.get(playerId);
+ if (cached != null) {
+ return cached;
}
-
- return Set.copyOf(ids);
+ Set loaded = loadFavorites(playerId);
+ cache.put(playerId, loaded);
+ return loaded;
}
public @NotNull ItemStack decorate(@NotNull UUID playerId, @NotNull HeadId headId, @NotNull ItemStack item) {
@@ -102,6 +92,39 @@ public synchronized boolean toggle(@NotNull UUID playerId, @NotNull HeadId headI
return item;
}
+ public synchronized void invalidate(@NotNull UUID playerId) {
+ Objects.requireNonNull(playerId, "playerId");
+ cache.remove(playerId);
+ }
+
+ public synchronized void invalidateAll() {
+ cache.clear();
+ }
+
+ private @NotNull Set loadFavorites(@NotNull UUID playerId) {
+ LinkedHashSet ids = new LinkedHashSet<>();
+ try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT head_id FROM headdb_favorites WHERE player_id = ? ORDER BY created_at ASC")) {
+ statement.setString(1, playerId.toString());
+ try (ResultSet result = statement.executeQuery()) {
+ while (result.next()) {
+ try {
+ ids.add(new HeadId(result.getString("head_id")));
+ } catch (IllegalArgumentException ignored) {
+ // Ignore stale malformed rows.
+ }
+ }
+ }
+ } catch (SQLException exception) {
+ throw new IllegalStateException("Failed to list HeadDB favorites.", exception);
+ }
+
+ return immutable(ids);
+ }
+
+ private static @NotNull Set immutable(@NotNull Set ids) {
+ return Collections.unmodifiableSet(new LinkedHashSet<>(ids));
+ }
+
private void add(@NotNull UUID playerId, @NotNull HeadId headId) {
try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT OR IGNORE INTO headdb_favorites(player_id, head_id, created_at) VALUES (?, ?, strftime('%s','now'))")) {
statement.setString(1, playerId.toString());
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoritesMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoritesMenu.java
index 9bd7d23..aad7d7f 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoritesMenu.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/favorites/FavoritesMenu.java
@@ -73,6 +73,7 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, in
renderHeads(plugin, player, inventory, heads, page);
renderControls(plugin, inventory, page, pages);
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) {
@@ -96,6 +97,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
Optional action = readAction(plugin, item);
if (action.isPresent()) {
+ plugin.sounds().playGuiAction(player, action.get());
handleAction(plugin, player, holder, action.get());
return true;
}
@@ -108,10 +110,12 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
if (event.getClick() == ClickType.RIGHT || event.getClick() == ClickType.SHIFT_RIGHT) {
if (!Permissions.has(player, Permissions.FAVORITES_TOGGLE)) {
player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION);
return true;
}
- plugin.favorites().toggle(player.getUniqueId(), id.get());
+ boolean added = plugin.favorites().toggle(player.getUniqueId(), id.get());
+ plugin.sounds().play(player, added ? io.github.silentdevelopment.headdb.paper.sound.SoundKey.FAVORITE_ADD : io.github.silentdevelopment.headdb.paper.sound.SoundKey.FAVORITE_REMOVE);
open(plugin, player, holder.page());
return true;
}
@@ -187,7 +191,7 @@ private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player p
}
}
- private static void give(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id) {
+ public static void give(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HeadId id) {
if (!Permissions.has(player, Permissions.GUI_HEAD_TAKE)) {
player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION));
return;
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/hidden/HiddenHeadsMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/hidden/HiddenHeadsMenu.java
index c55bf30..a13eb8f 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/hidden/HiddenHeadsMenu.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/hidden/HiddenHeadsMenu.java
@@ -6,6 +6,7 @@
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.GuiLabels;
import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles;
import io.github.silentdevelopment.headdb.paper.item.HeadItemIds;
import io.github.silentdevelopment.headdb.paper.local.override.RemoteHeadOverride;
@@ -70,13 +71,14 @@ public static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, in
int pages = pageCount(heads.size());
int page = clampPage(requestedPage, pages);
HiddenHeadsHolder holder = new HiddenHeadsHolder(page);
- Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title("Hidden Heads " + (page + 1) + "/" + pages, true));
+ Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(title(plugin, page, pages), true));
holder.inventory(inventory);
fillBorder(inventory);
renderHeads(plugin, player, inventory, heads, page);
renderControls(plugin, inventory, heads.size(), page, pages);
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) {
@@ -91,6 +93,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
event.setCancelled(true);
if (!Permissions.has(player, Permissions.GUI_HIDDEN_HEADS) || !plugin.adminModes().enabled(player)) {
player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION);
player.closeInventory();
return true;
}
@@ -105,6 +108,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
Optional action = readAction(plugin, item);
if (action.isPresent()) {
+ plugin.sounds().playGuiAction(player, action.get());
handleAction(plugin, player, holder, action.get());
return true;
}
@@ -117,10 +121,12 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
if (event.getClick() == ClickType.RIGHT || event.getClick() == ClickType.SHIFT_RIGHT) {
if (!Permissions.has(player, Permissions.FAVORITES_TOGGLE)) {
player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION);
return true;
}
- plugin.favorites().toggle(player.getUniqueId(), id.get());
+ boolean added = plugin.favorites().toggle(player.getUniqueId(), id.get());
+ plugin.sounds().play(player, added ? io.github.silentdevelopment.headdb.paper.sound.SoundKey.FAVORITE_ADD : io.github.silentdevelopment.headdb.paper.sound.SoundKey.FAVORITE_REMOVE);
open(plugin, player, holder.page());
return true;
}
@@ -130,7 +136,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
plugin.headRegistry().onLocalMutation();
plugin.clearItemCache();
plugin.clearSearchCache();
- player.sendMessage(Component.text("Head shown: ", NamedTextColor.GRAY).append(Component.text(id.get().display(), NamedTextColor.GOLD)));
+ player.sendMessage(Component.text("Head shown: ", NamedTextColor.GRAY).append(Component.text(GuiLabels.head(plugin, player, id.get()), NamedTextColor.GOLD)));
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.SHOW_HEAD);
open(plugin, player, holder.page());
return true;
}
@@ -173,7 +180,7 @@ private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Invent
private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull HiddenHeadsHolder holder, @NotNull String action) {
if (action.equals(ACTION_BACK)) {
- plugin.guis().openMain(player);
+ plugin.guis().openBrowse(player);
return;
}
@@ -242,6 +249,10 @@ private static void fillBorder(@NotNull Inventory inventory) {
return id.display();
}
+ private static @NotNull String title(@NotNull HeadDBPlugin plugin, int page, int pages) {
+ return plugin.guiConfig().text("title.hidden-heads-page", "Hidden Heads %page%/%pages%").replace("%page%", String.valueOf(page + 1)).replace("%pages%", String.valueOf(Math.max(1, pages)));
+ }
+
private static int pageCount(int entries) {
if (entries <= 0) {
return 1;
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/local/LocalHeadListMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/local/LocalHeadListMenu.java
index 36f8f87..eaccbd8 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/local/LocalHeadListMenu.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/local/LocalHeadListMenu.java
@@ -10,6 +10,7 @@
import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles;
import io.github.silentdevelopment.headdb.paper.item.HeadItemIds;
import io.github.silentdevelopment.headdb.paper.local.player.PlayerHeadEntry;
+import io.github.silentdevelopment.headdb.paper.local.custom.StoredCustomHead;
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
@@ -43,7 +44,7 @@ public final class LocalHeadListMenu {
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 = 53;
+ private static final int SLOT_NEXT = 50;
private static final int[] HEAD_SLOTS = {
10, 11, 12, 13, 14, 15, 16,
19, 20, 21, 22, 23, 24, 25,
@@ -67,7 +68,7 @@ public static void openCustom(@NotNull HeadDBPlugin plugin, @NotNull Player play
Objects.requireNonNull(plugin, "plugin");
Objects.requireNonNull(player, "player");
- List heads = plugin.headRegistry().customHeads().list().stream().sorted(Comparator.comparing(Head::name, String.CASE_INSENSITIVE_ORDER)).toList();
+ List heads = (plugin.adminModes().enabled(player) ? plugin.headRegistry().customHeads().listStored().stream().map(StoredCustomHead::toHead).toList() : plugin.headRegistry().customHeads().list()).stream().sorted(Comparator.comparing(Head::name, String.CASE_INSENSITIVE_ORDER)).toList();
open(plugin, player, LocalHeadListType.CUSTOM, heads, page);
}
@@ -107,6 +108,7 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
Optional action = readAction(plugin, item);
if (action.isPresent()) {
+ plugin.sounds().playGuiAction(player, action.get());
handleAction(plugin, player, holder, action.get());
return true;
}
@@ -124,10 +126,12 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
if (event.getClick() == ClickType.RIGHT || event.getClick() == ClickType.SHIFT_RIGHT) {
if (!Permissions.has(player, Permissions.FAVORITES_TOGGLE)) {
player.sendMessage(plugin.messages().render(player, io.github.silentdevelopment.headdb.paper.message.MessageKey.COMMAND_ERROR_NO_PERMISSION));
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.NO_PERMISSION);
return true;
}
- plugin.favorites().toggle(player.getUniqueId(), id.get());
+ boolean added = plugin.favorites().toggle(player.getUniqueId(), id.get());
+ plugin.sounds().play(player, added ? io.github.silentdevelopment.headdb.paper.sound.SoundKey.FAVORITE_ADD : io.github.silentdevelopment.headdb.paper.sound.SoundKey.FAVORITE_REMOVE);
open(plugin, player, holder.type(), holder.page());
return true;
}
@@ -146,7 +150,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @
int page = clampPage(requestedPage, pages);
LocalHeadListHolder holder = new LocalHeadListHolder(type, page);
boolean adminMode = plugin.adminModes().enabled(player);
- Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(title(type, page, pages), adminMode));
+ Inventory inventory = Bukkit.createInventory(holder, SIZE, GuiTitles.title(title(plugin, type, page, pages), adminMode));
holder.inventory(inventory);
fillBorder(inventory);
@@ -154,6 +158,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @
renderControls(plugin, inventory, type, page, pages, heads.size(), adminMode);
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
private static void renderHeads(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull Inventory inventory, @NotNull List heads, int page, boolean adminMode) {
@@ -195,6 +200,11 @@ private static void renderControls(@NotNull HeadDBPlugin plugin, @NotNull Invent
private static void handleAction(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull LocalHeadListHolder holder, @NotNull String action) {
if (action.equals(ACTION_BACK)) {
+ if (holder.type() == LocalHeadListType.CUSTOM) {
+ plugin.guis().openBrowse(player);
+ return;
+ }
+
plugin.guis().openMain(player);
return;
}
@@ -283,8 +293,10 @@ private static void fillBorder(@NotNull Inventory inventory) {
return new org.bukkit.NamespacedKey(plugin, "local_menu_action");
}
- private static @NotNull String title(@NotNull LocalHeadListType type, int page, int pages) {
- return type.displayName() + " " + (page + 1) + "/" + pages;
+ private static @NotNull String title(@NotNull HeadDBPlugin plugin, @NotNull LocalHeadListType type, int page, int pages) {
+ String key = type == LocalHeadListType.CUSTOM ? "title.custom-heads-page" : "title.player-heads-page";
+ String fallback = type.displayName() + " %page%/%pages%";
+ return plugin.guiConfig().text(key, fallback).replace("%page%", String.valueOf(page + 1)).replace("%pages%", String.valueOf(Math.max(1, pages)));
}
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/main/MainPageFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/main/MainPageFactory.java
index ac1b0b7..69cbc1f 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/main/MainPageFactory.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/main/MainPageFactory.java
@@ -9,17 +9,11 @@
import io.github.silentdevelopment.grafik.paper.page.PaperPageFactory;
import io.github.silentdevelopment.headdb.database.DatabaseStats;
import io.github.silentdevelopment.headdb.database.DatabaseStatus;
-import io.github.silentdevelopment.headdb.model.Head;
-import io.github.silentdevelopment.headdb.model.HeadCategory;
import io.github.silentdevelopment.headdb.paper.HeadDBPlugin;
import io.github.silentdevelopment.headdb.paper.gui.MenuState;
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;
-import io.github.silentdevelopment.headdb.paper.gui.hidden.HiddenHeadsMenu;
-import io.github.silentdevelopment.headdb.paper.gui.config.GuiButtonEditorMenu;
-import io.github.silentdevelopment.headdb.paper.gui.config.GuiIconConfig;
-import io.github.silentdevelopment.headdb.paper.item.HeadItemIds;
import io.github.silentdevelopment.headdb.paper.gui.settings.SettingsPageFactory;
import io.github.silentdevelopment.headdb.paper.message.MessageKey;
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
@@ -30,18 +24,13 @@
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit;
-import org.bukkit.Material;
import org.bukkit.entity.Player;
-import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.MenuType;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
-import java.util.Comparator;
import java.util.HashSet;
-import java.util.LinkedHashMap;
import java.util.List;
-import java.util.Map;
import java.util.Objects;
import java.util.Set;
@@ -50,16 +39,8 @@ public final class MainPageFactory implements PaperPageFactory {
public static final GKey KEY = GKey.of("main");
- private static final String CATEGORY_PAGE_STATE_KEY = "headdb.main.category-page";
private static final int ROWS = 6;
private static final int SLOT_CLOSE = 45;
- private static final int SLOT_PREVIOUS = 48;
- private static final int SLOT_NEXT = 50;
- private static final int[] CATEGORY_SLOTS = {
- 10, 11, 12, 13, 14, 15, 16,
- 19, 20, 21, 22, 23, 24, 25,
- 37, 38, 39, 40, 41, 42, 43
- };
private final HeadDBPlugin plugin;
@@ -79,147 +60,54 @@ public MainPageFactory(@NotNull HeadDBPlugin plugin) {
Player player = player(context);
boolean adminMode = player != null && plugin.adminModes().enabled(player);
+ Set reservedSlots = new HashSet<>();
page.type(MenuType.GENERIC_9X6);
- page.title(GuiTitles.title("HeadDB", adminMode));
-
- Set reservedSlots = new HashSet<>();
- renderCategories(context, page, reservedSlots, adminMode);
+ page.title(GuiTitles.title(plugin.guiConfig().text("title.main", "HeadDB"), adminMode));
renderControls(context, page, reservedSlots, adminMode);
GuiItems.fillEmpty(plugin, page, ROWS, reservedSlots);
-
return page.build();
}
- private void renderCategories(@NotNull GuiContext context, @NotNull PaperPageBuilder page, @NotNull Set reservedSlots, boolean adminMode) {
- Player player = player(context);
- if (player == null) {
- return;
- }
-
- if (!Permissions.has(player, Permissions.GUI_CATEGORY_VIEW)) {
- return;
- }
-
- List categories = categories(player);
- Map iconHeads = categoryIconHeads(categories, adminMode);
- Map counts = plugin.headRegistry().categoryCounts(adminMode);
- int pageIndex = categoryPage(context);
- int fromIndex = pageIndex * CATEGORY_SLOTS.length;
-
- if (fromIndex >= categories.size() && pageIndex > 0) {
- pageIndex = Math.max(0, pageCount(categories.size()) - 1);
- setCategoryPage(context, pageIndex);
- fromIndex = pageIndex * CATEGORY_SLOTS.length;
- }
-
- int toIndex = Math.min(categories.size(), fromIndex + CATEGORY_SLOTS.length);
- int slotIndex = 0;
- for (int index = fromIndex; index < toIndex; index++) {
- HeadCategory category = categories.get(index);
- int slot = CATEGORY_SLOTS[slotIndex];
- set(page, reservedSlots, slot, categoryButton(category, iconHeads.get(category.id()), counts.getOrDefault(category.id(), 0), adminMode));
- slotIndex++;
- }
- }
-
private void renderControls(@NotNull GuiContext context, @NotNull PaperPageBuilder page, @NotNull Set reservedSlots, boolean adminMode) {
Player player = player(context);
if (player == null) {
return;
}
- List categories = categories(player);
- int categoryPage = categoryPage(context);
- int categoryPages = pageCount(categories.size());
-
set(page, reservedSlots, SLOT_CLOSE, closeButton());
- if (Permissions.has(player, Permissions.GUI_BROWSE) && Permissions.canViewAllCategories(player)) {
- set(page, reservedSlots, slot("main.browse-heads", 31), browseAllButton());
- }
-
if (Permissions.has(player, Permissions.GUI_PLAYER_HEADS)) {
- set(page, reservedSlots, slot("main.player-heads", 37), playerHeadsButton());
+ set(page, reservedSlots, slot("main.player-heads", 11), playerHeadsButton());
}
- if (Permissions.has(player, Permissions.GUI_CUSTOM_HEADS)) {
- set(page, reservedSlots, slot("main.custom-heads", 28), customHeadsButton());
+ if (Permissions.has(player, Permissions.GUI_BROWSE_MENU)) {
+ set(page, reservedSlots, slot("main.browse", 13), browseButton());
}
if (Permissions.has(player, Permissions.GUI_FAVORITES)) {
- set(page, reservedSlots, slot("main.favorites", 40), favoritesButton());
- }
-
- if (adminMode && Permissions.has(player, Permissions.GUI_HIDDEN_HEADS)) {
- set(page, reservedSlots, slot("main.hidden-heads", 51), hiddenHeadsButton());
+ set(page, reservedSlots, slot("main.favorites", 15), favoritesButton());
}
if (Permissions.has(player, Permissions.GUI_SEARCH)) {
- set(page, reservedSlots, slot("main.search", 43), searchButton());
- }
-
- if (Permissions.has(player, Permissions.GUI_MORE_CATEGORIES)) {
- set(page, reservedSlots, slot("main.more-categories", 34), moreCategoriesButton());
- }
-
- if (categoryPage > 0) {
- set(page, reservedSlots, SLOT_PREVIOUS, previousCategoriesButton(categoryPage));
+ set(page, reservedSlots, slot("main.search", 31), searchButton());
}
- set(page, reservedSlots, slot("main.info", 49), infoButton(categories.size(), adminMode));
-
- if (categoryPage + 1 < categoryPages) {
- set(page, reservedSlots, SLOT_NEXT, nextCategoriesButton(categoryPage));
- }
+ set(page, reservedSlots, slot("main.info", 49), infoButton(visibleCategories(player), adminMode));
if (Permissions.has(player, Permissions.GUI_SETTINGS)) {
set(page, reservedSlots, slot("main.settings", 53), settingsButton());
}
}
- private @NotNull ItemElement categoryButton(@NotNull HeadCategory category, Head iconHead, int amount, boolean adminMode) {
- Objects.requireNonNull(category, "category");
-
- return ItemElement.of(GKey.of("category_" + category.id()), context -> {
- Player player = player(context);
- boolean showDetails = player != null && adminMode;
- return categoryItem(category, iconHead, amount, showDetails);
- }, context -> {
- Player player = player(context);
- if (player == null) {
- return;
- }
-
- if (!Permissions.has(player, Permissions.GUI_CATEGORY_OPEN) || !Permissions.canViewCategory(player, category.id())) {
- player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
- return;
- }
-
- plugin.guis().openSearch(player, categoryRequest(category));
- });
- }
-
private @NotNull ItemElement closeButton() {
return GuiHeadIcons.button(plugin, "close", "close", context -> {
Player player = player(context);
- if (player != null) {
- player.closeInventory();
+ if (player == null) {
+ return;
}
- });
- }
-
- private @NotNull ItemElement previousCategoriesButton(int currentPage) {
- return GuiHeadIcons.button(plugin, "previous_categories", "previous", context -> {
- setCategoryPage(context, currentPage - 1);
- context.refresh();
- });
- }
- private @NotNull ItemElement nextCategoriesButton(int currentPage) {
- return GuiHeadIcons.button(plugin, "next_categories", "next", context -> {
- setCategoryPage(context, currentPage + 1);
- context.refresh();
+ player.closeInventory();
});
}
@@ -240,7 +128,7 @@ private void renderControls(@NotNull GuiContext context, @NotNull Pap
lore.add(GuiItems.idDetail("Hidden heads", plugin.headRegistry().hiddenHeads().size()));
}
- return GuiHeadIcons.button(plugin, "main_info", "info", GuiItems.name("HeadDB", adminMode ? NamedTextColor.RED : NamedTextColor.GOLD), lore, ignored -> {});
+ return GuiHeadIcons.button(plugin, "main_info", "info", GuiItems.mini(plugin.guiConfig().icon("info").name()), lore, ignored -> {});
}
private @NotNull ItemElement searchButton() {
@@ -266,70 +154,39 @@ private void renderControls(@NotNull GuiContext context, @NotNull Pap
private @NotNull ItemElement playerHeadsButton() {
return GuiHeadIcons.button(plugin, "player_heads", plugin.guiConfig().icon("player-heads"), context -> {
Player player = player(context);
- if (player != null) {
- plugin.guis().openPlayerHeads(player);
+ if (player == null) {
+ return;
}
- }, context -> mainButtonName("player-heads"), context -> mainButtonLore(context, "player-heads", plugin.headRegistry().playerHeads().knownPlayers().size()));
- }
- private @NotNull ItemElement customHeadsButton() {
- return GuiHeadIcons.button(plugin, "custom_heads", plugin.guiConfig().icon("custom-heads"), context -> {
- Player player = player(context);
- if (player != null) {
- plugin.guis().openCustomHeads(player);
- }
- }, context -> mainButtonName("custom-heads"), context -> mainButtonLore(context, "custom-heads", plugin.headRegistry().customHeads().list().size()));
+ plugin.guis().openPlayerHeads(player);
+ }, context -> mainButtonName("player-heads"), context -> mainButtonLore(context, "player-heads", plugin.headRegistry().playerHeads().knownPlayers().size()));
}
-
private @NotNull ItemElement favoritesButton() {
return GuiHeadIcons.button(plugin, "favorites", "favorites", context -> {
- Player player = player(context);
- if (player != null) {
- plugin.guis().openFavorites(player);
- }
- });
- }
-
- private @NotNull ItemElement moreCategoriesButton() {
- return GuiHeadIcons.button(plugin, "more_categories", "more-categories", context -> {
- Player player = player(context);
- if (player != null) {
- plugin.guis().openMoreCategories(player);
- }
- });
- }
-
- private @NotNull ItemElement hiddenHeadsButton() {
- return GuiHeadIcons.button(plugin, "hidden_heads", "hidden-heads", context -> {
Player player = player(context);
if (player == null) {
return;
}
- if (!plugin.adminModes().enabled(player) || !Permissions.has(player, Permissions.GUI_HIDDEN_HEADS)) {
- player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
- return;
- }
-
- HiddenHeadsMenu.open(plugin, player);
+ plugin.guis().openFavorites(player);
});
}
- private @NotNull ItemElement browseAllButton() {
- return GuiHeadIcons.button(plugin, "browse_all", plugin.guiConfig().icon("browse-all"), context -> {
+ private @NotNull ItemElement browseButton() {
+ return GuiHeadIcons.button(plugin, "browse", plugin.guiConfig().icon("browse"), context -> {
Player player = player(context);
if (player == null) {
return;
}
- if (!Permissions.has(player, Permissions.GUI_BROWSE) || !Permissions.canViewAllCategories(player)) {
+ if (!Permissions.has(player, Permissions.GUI_BROWSE_MENU)) {
player.sendMessage(plugin.messages().render(player, MessageKey.COMMAND_ERROR_NO_PERMISSION));
return;
}
- plugin.guis().openSearch(player, browseRequest());
- }, context -> mainButtonName("browse-all"), context -> mainButtonLore(context, "browse-all", plugin.headRegistry().heads(adminMode(context)).size()));
+ plugin.guis().openBrowse(player);
+ }, context -> mainButtonName("browse"), context -> mainButtonLore(context, "browse", visibleCategories(context)));
}
private @NotNull ItemElement settingsButton() {
@@ -366,162 +223,24 @@ private boolean adminMode(@NotNull GuiContext context) {
return player != null && plugin.adminModes().enabled(player);
}
- private @NotNull ItemStack categoryItem(@NotNull HeadCategory category, Head iconHead, int amount, boolean showTechnicalDetails) {
- Objects.requireNonNull(category, "category");
-
- String iconKey = categoryIconKey(category.id());
- boolean configured = plugin.guiConfig().hasIcon(iconKey);
- GuiIconConfig icon = plugin.guiConfig().iconOrDefault(iconKey, "category");
- ItemStack item = categoryIcon(category, iconHead, configured, icon);
-
- item.editMeta(meta -> {
- List lore = new ArrayList<>();
-
- if (configured) {
- lore.addAll(GuiItems.miniLore(replaceAll(icon.lore(), category, amount)));
- } else {
- String description = category.description();
- if (description != null && !description.isBlank()) {
- lore.add(Component.text(description, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false));
- }
- }
-
- if (!lore.isEmpty()) {
- lore.add(Component.empty());
- }
-
- if (showTechnicalDetails) {
- lore.add(GuiItems.idDetail("Heads", amount));
- lore.add(GuiItems.idDetail("ID", category.id()));
- lore.add(Component.empty());
- }
-
- lore.add(Component.text("Click to browse this category.", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false));
- Component name = configured ? GuiItems.mini(replace(icon.name(), category, amount)) : Component.text(categoryTitle(category.name(), amount), NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false);
- meta.displayName(name);
- meta.lore(lore);
- meta.getPersistentDataContainer().remove(HeadItemIds.key(plugin));
- meta.getPersistentDataContainer().set(GuiButtonEditorMenu.iconKey(plugin), org.bukkit.persistence.PersistentDataType.STRING, iconKey);
- });
-
- return item;
- }
-
- private @NotNull ItemStack categoryIcon(@NotNull HeadCategory category, Head iconHead, boolean configured, @NotNull GuiIconConfig icon) {
- if (configured) {
- return GuiHeadIcons.icon(plugin, icon);
- }
-
- if (iconHead == null) {
- return GuiHeadIcons.icon(plugin, plugin.guiConfig().icon("category"));
- }
-
- return plugin.itemFactory().create(iconHead);
- }
-
-
- private static @NotNull String categoryIconKey(@NotNull String categoryId) {
- Objects.requireNonNull(categoryId, "categoryId");
- return "category." + categoryId.trim().toLowerCase(java.util.Locale.ROOT).replaceAll("[^a-z0-9_.-]", "_");
- }
-
- private static @NotNull String replace(@NotNull String value, @NotNull HeadCategory category, int amount) {
- Objects.requireNonNull(value, "value");
- Objects.requireNonNull(category, "category");
-
- return value.replace("%name%", category.name()).replace("%id%", category.id()).replace("%count%", String.valueOf(amount));
- }
-
- private static @NotNull List replaceAll(@NotNull List values, @NotNull HeadCategory category, int amount) {
- Objects.requireNonNull(values, "values");
- Objects.requireNonNull(category, "category");
-
- List result = new ArrayList<>();
- for (String value : values) {
- result.add(replace(value, category, amount));
- }
- return List.copyOf(result);
- }
-
- private @NotNull Map categoryIconHeads(@NotNull List categories, boolean includeHidden) {
- Objects.requireNonNull(categories, "categories");
-
- Map icons = new LinkedHashMap<>();
- Set missing = new HashSet<>();
- for (HeadCategory category : categories) {
- missing.add(category.id());
- }
-
- if (missing.isEmpty()) {
- return icons;
- }
-
- List candidateHeads = new ArrayList<>(plugin.headRegistry().heads(includeHidden));
-
- for (Head head : candidateHeads) {
- String category = head.category();
- if (!missing.contains(category)) {
- continue;
- }
-
- icons.put(category, head);
- missing.remove(category);
- if (missing.isEmpty()) {
- return icons;
- }
- }
-
- for (Head head : plugin.headRegistry().customHeads().list()) {
- String category = head.category();
- if (!missing.contains(category)) {
- continue;
- }
-
- icons.put(category, head);
- missing.remove(category);
- if (missing.isEmpty()) {
- return icons;
- }
+ private int visibleCategories(@NotNull GuiContext context) {
+ Player player = player(context);
+ if (player == null) {
+ return 0;
}
- return icons;
+ return visibleCategories(player);
}
- private @NotNull List categories(@NotNull Player player) {
+ private int visibleCategories(@NotNull Player player) {
Objects.requireNonNull(player, "player");
- return plugin.headRegistry().categories().stream().filter(category -> Permissions.canViewCategory(player, category.id())).sorted(Comparator.comparing(HeadCategory::name, String.CASE_INSENSITIVE_ORDER)).toList();
- }
-
-
- private @NotNull String categoryTitle(@NotNull String name, int amount) {
- return plugin.guiConfig().text("category-count", "%name% (%count%)").replace("%name%", name).replace("%count%", String.valueOf(amount));
+ return (int) plugin.headRegistry().categories().stream().filter(category -> Permissions.canViewCategory(player, category.id())).count();
}
private int slot(@NotNull String key, int fallback) {
return plugin.guiConfig().slot(key, fallback);
}
- private static int categoryPage(@NotNull GuiContext context) {
- Integer value = context.state().get(CATEGORY_PAGE_STATE_KEY, Integer.class);
- if (value == null || value < 0) {
- return 0;
- }
-
- return value;
- }
-
- private static void setCategoryPage(@NotNull GuiContext context, int page) {
- context.state().put(CATEGORY_PAGE_STATE_KEY, Math.max(0, page));
- }
-
- private static int pageCount(int entries) {
- if (entries <= 0) {
- return 1;
- }
-
- return (int) Math.ceil((double) entries / (double) CATEGORY_SLOTS.length);
- }
-
private static Player player(@NotNull GuiContext context) {
Objects.requireNonNull(context, "context");
return Bukkit.getPlayer(context.source().viewerId());
@@ -540,15 +259,6 @@ private static void sendSearchCancelled(@NotNull Player player) {
player.sendMessage(Component.text("Search cancelled.", NamedTextColor.GRAY));
}
- private static @NotNull SearchRequest browseRequest() {
- return new SearchRequest("", Set.of(), Set.of(), Set.of(), Set.of(), HeadSort.ID, SortDirection.ASCENDING, 1, 28, false);
- }
-
- private static @NotNull SearchRequest categoryRequest(@NotNull HeadCategory category) {
- Objects.requireNonNull(category, "category");
- return new SearchRequest("", Set.of(), Set.of(category.id()), Set.of(), Set.of(), HeadSort.ID, SortDirection.ASCENDING, 1, 28, true);
- }
-
private static void set(@NotNull PaperPageBuilder page, @NotNull Set reservedSlots, int slot, @NotNull ItemElement element) {
page.set(slot, element);
reservedSlots.add(slot);
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/material/MaterialSelectionMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/material/MaterialSelectionMenu.java
index dd8fe0a..4a4274b 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/material/MaterialSelectionMenu.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/material/MaterialSelectionMenu.java
@@ -33,7 +33,7 @@ public final class MaterialSelectionMenu {
private static final int SLOT_BACK = 45;
private static final int SLOT_PREVIOUS = 48;
private static final int SLOT_TYPE = 49;
- private static final int SLOT_NEXT = 53;
+ private static final int SLOT_NEXT = 50;
private static final String ACTION_BACK = "back";
private static final String ACTION_PREVIOUS = "previous";
private static final String ACTION_NEXT = "next";
@@ -74,6 +74,7 @@ private static void open(@NotNull HeadDBPlugin plugin, @NotNull Player player, @
renderControls(plugin, inventory, page, pages);
player.openInventory(inventory);
+ plugin.sounds().play(player, io.github.silentdevelopment.headdb.paper.sound.SoundKey.MENU_OPEN);
}
public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player player, @NotNull InventoryClickEvent event) {
@@ -100,6 +101,8 @@ public static boolean handleClick(@NotNull HeadDBPlugin plugin, @NotNull Player
return true;
}
+ plugin.sounds().playGuiAction(player, action.get());
+
handleAction(plugin, player, holder, action.get());
return true;
}
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchFilterPageFactory.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchFilterPageFactory.java
index 15f9d94..0362929 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchFilterPageFactory.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchFilterPageFactory.java
@@ -7,6 +7,7 @@
import io.github.silentdevelopment.grafik.paper.page.PaperPage;
import io.github.silentdevelopment.grafik.paper.page.PaperPageBuilder;
import io.github.silentdevelopment.grafik.paper.page.PaperPageFactory;
+import io.github.silentdevelopment.headdb.model.Head;
import io.github.silentdevelopment.headdb.model.HeadCategory;
import io.github.silentdevelopment.headdb.model.HeadCollection;
import io.github.silentdevelopment.headdb.model.HeadTag;
@@ -17,7 +18,6 @@
import io.github.silentdevelopment.headdb.paper.gui.common.GuiTitles;
import io.github.silentdevelopment.headdb.paper.message.MessageKey;
import io.github.silentdevelopment.headdb.paper.permission.Permissions;
-import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
@@ -30,6 +30,7 @@
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
+import java.util.UUID;
@SuppressWarnings("UnstableApiUsage")
public final class SearchFilterPageFactory implements PaperPageFactory {
@@ -77,7 +78,7 @@ public SearchFilterPageFactory(@NotNull HeadDBPlugin plugin, @NotNull Mode mode)
set(page, reservedSlots, SLOT_BACK, backButton());
- if (player == null || !Permissions.has(player, Permissions.GUI_FILTER)) {
+ if (player == null || !Permissions.has(player, Permissions.GUI_FILTER) || !Permissions.has(player, mode.permission())) {
set(page, reservedSlots, 22, deniedButton());
GuiItems.fillEmpty(plugin, page, ROWS, reservedSlots);
return page.build();
@@ -91,7 +92,7 @@ public SearchFilterPageFactory(@NotNull HeadDBPlugin plugin, @NotNull Mode mode)
return page.build();
}
- List entries = entries(player);
+ List entries = cachedEntries(context, player);
int pageIndex = pageIndex(context);
int fromIndex = pageIndex * ENTRY_SLOTS.length;
@@ -226,9 +227,9 @@ private boolean hasSelectedFilter(@NotNull SearchRequest request) {
"info",
GuiItems.name(mode.title(), NamedTextColor.GOLD),
List.of(
- GuiItems.lore("Entries: " + entries, NamedTextColor.GRAY),
- GuiItems.lore("Selected: " + selectedCount(request), NamedTextColor.GRAY),
- GuiItems.lore("Page: " + (pageIndex + 1) + " / " + Math.max(1, pages), NamedTextColor.GRAY)
+ GuiItems.idDetail("Entries", entries),
+ GuiItems.idDetail("Selected", selectedCount(request)),
+ GuiItems.idDetail("Page", (pageIndex + 1) + " / " + Math.max(1, pages))
),
ignored -> {}
);
@@ -249,7 +250,7 @@ private int selectedCount(@NotNull SearchRequest request) {
"filter-category",
GuiItems.name("Category Scope", NamedTextColor.GOLD),
List.of(
- GuiItems.lore("Current: " + request.category(), NamedTextColor.GRAY),
+ GuiItems.idDetail("Current", request.category()),
GuiItems.lore("Use tag or collection filters inside this category.", NamedTextColor.DARK_GRAY)
),
ignored -> {}
@@ -285,27 +286,73 @@ private int selectedCount(@NotNull SearchRequest request) {
});
}
- private @NotNull List entries(@NotNull Player player) {
- return switch (mode) {
- case CATEGORY -> plugin.headRegistry().categories()
- .stream()
+ private @NotNull List cachedEntries(@NotNull GuiContext context, @NotNull Player player) {
+ Objects.requireNonNull(context, "context");
+ Objects.requireNonNull(player, "player");
+
+ String stateKey = cacheStateKey();
+ FilterEntryCache cache = context.state().get(stateKey, FilterEntryCache.class);
+ UUID viewerId = player.getUniqueId();
+ long registryRevision = plugin.headRegistry().revision();
+ boolean adminMode = plugin.adminModes().enabled(player);
+
+ if (cache != null && cache.matches(viewerId, registryRevision, adminMode)) {
+ return cache.entries();
+ }
+
+ List entries = entries(player, adminMode);
+ context.state().put(stateKey, new FilterEntryCache(viewerId, registryRevision, adminMode, entries));
+ return entries;
+ }
+
+ private @NotNull String cacheStateKey() {
+ return "headdb.search.filter.entries." + mode.name().toLowerCase(Locale.ROOT);
+ }
+
+ private @NotNull List entries(@NotNull Player player, boolean adminMode) {
+ if (mode == Mode.CATEGORY) {
+ return plugin.headRegistry().categories().stream()
.filter(category -> Permissions.canViewCategory(player, category.id()))
.sorted(Comparator.comparing(HeadCategory::name, String.CASE_INSENSITIVE_ORDER))
.map(category -> new FilterEntry(category.id(), category.name()))
.toList();
+ }
- case TAGS -> plugin.headRegistry().tags()
- .stream()
+ if (mode == Mode.TAGS) {
+ Set visibleTags = visibleTagIds(player, adminMode);
+ return plugin.headRegistry().tags().stream()
+ .filter(tag -> adminMode || visibleTags.contains(tag.id()))
.sorted(Comparator.comparing(HeadTag::name, String.CASE_INSENSITIVE_ORDER))
.map(tag -> new FilterEntry(tag.id(), tag.name()))
.toList();
+ }
- case COLLECTIONS -> plugin.headRegistry().collections()
- .stream()
- .sorted(Comparator.comparing(HeadCollection::name, String.CASE_INSENSITIVE_ORDER))
- .map(collection -> new FilterEntry(collection.id(), collection.name()))
- .toList();
- };
+ Set visibleCollections = visibleCollectionIds(player, adminMode);
+ return plugin.headRegistry().collections().stream()
+ .filter(collection -> adminMode || visibleCollections.contains(collection.id()))
+ .sorted(Comparator.comparing(HeadCollection::name, String.CASE_INSENSITIVE_ORDER))
+ .map(collection -> new FilterEntry(collection.id(), collection.name()))
+ .toList();
+ }
+
+ private @NotNull Set visibleTagIds(@NotNull Player player, boolean adminMode) {
+ Set tags = new HashSet<>();
+ for (Head head : visibleHeads(player, adminMode)) {
+ tags.addAll(head.tags());
+ }
+ return Set.copyOf(tags);
+ }
+
+ private @NotNull Set visibleCollectionIds(@NotNull Player player, boolean adminMode) {
+ Set collections = new HashSet<>();
+ for (Head head : visibleHeads(player, adminMode)) {
+ collections.addAll(head.collections());
+ }
+ return Set.copyOf(collections);
+ }
+
+ private @NotNull List visibleHeads(@NotNull Player player, boolean adminMode) {
+ return plugin.headRegistry().heads(adminMode).stream().filter(head -> Permissions.canViewCategory(player, head.category())).toList();
}
private int pageIndex(@NotNull GuiContext context) {
@@ -346,22 +393,36 @@ private static void set(@NotNull PaperPageBuilder page, @NotNul
private record FilterEntry(@NotNull String id, @NotNull String name) {}
+ private record FilterEntryCache(@NotNull UUID viewerId, long registryRevision, boolean adminMode, @NotNull List entries) {
+
+ private FilterEntryCache {
+ Objects.requireNonNull(viewerId, "viewerId");
+ entries = List.copyOf(Objects.requireNonNull(entries, "entries"));
+ }
+
+ private boolean matches(@NotNull UUID viewerId, long registryRevision, boolean adminMode) {
+ return this.viewerId.equals(viewerId) && this.registryRevision == registryRevision && this.adminMode == adminMode;
+ }
+ }
+
public enum Mode {
- CATEGORY(CATEGORY_KEY, "Category Filters", "Categories", "headdb.search.filter.category-page"),
- TAGS(TAGS_KEY, "Tag Filters", "Tags", "headdb.search.filter.tags-page"),
- COLLECTIONS(COLLECTIONS_KEY, "Collection Filters", "Collections", "headdb.search.filter.collections-page");
+ CATEGORY(CATEGORY_KEY, "Category Filters", "Categories", "headdb.search.filter.category-page", Permissions.GUI_FILTER_CATEGORIES),
+ TAGS(TAGS_KEY, "Tag Filters", "Tags", "headdb.search.filter.tags-page", Permissions.GUI_FILTER_TAGS),
+ COLLECTIONS(COLLECTIONS_KEY, "Collection Filters", "Collections", "headdb.search.filter.collections-page", Permissions.GUI_FILTER_COLLECTIONS);
private final GKey key;
private final String title;
private final String label;
private final String stateKey;
+ private final String permission;
- Mode(@NotNull GKey key, @NotNull String title, @NotNull String label, @NotNull String stateKey) {
+ Mode(@NotNull GKey key, @NotNull String title, @NotNull String label, @NotNull String stateKey, @NotNull String permission) {
this.key = key;
this.title = title;
this.label = label;
this.stateKey = stateKey;
+ this.permission = permission;
}
public @NotNull GKey key() {
@@ -379,5 +440,9 @@ public enum Mode {
public @NotNull String stateKey() {
return stateKey;
}
+
+ public @NotNull String permission() {
+ return permission;
+ }
}
}
\ No newline at end of file
diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenu.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenu.java
index cd05e07..4b9d1cd 100644
--- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenu.java
+++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/gui/search/SearchMenu.java
@@ -14,25 +14,38 @@
public final class SearchMenu implements PaperDynamicGuiDefinition {
private static final GKey KEY = GKey.of("headdb_search");
+ private static final GKey ADVANCED_KEY = GKey.of("headdb_search_advanced");
+ private final GKey