diff --git a/src/main/java/de/ticketsystem/TicketPlugin.java b/src/main/java/de/ticketsystem/TicketPlugin.java index 71be4eb..1355b1d 100644 --- a/src/main/java/de/ticketsystem/TicketPlugin.java +++ b/src/main/java/de/ticketsystem/TicketPlugin.java @@ -14,6 +14,8 @@ import de.ticketsystem.manager.LanguageManager; import de.ticketsystem.manager.TicketManager; import de.ticketsystem.model.Ticket; import de.ticketsystem.model.TicketPriority; +import de.ticketsystem.web.SessionManager; +import de.ticketsystem.web.WebServer; import org.bukkit.ChatColor; import org.bukkit.plugin.java.JavaPlugin; @@ -41,6 +43,8 @@ public class TicketPlugin extends JavaPlugin { private DiscordWebhook discordWebhook; private BungeeMessenger bungeeMessenger; private TicketCache ticketCache; + private SessionManager sessionManager; + private WebServer webServer; @Override public void onEnable() { @@ -91,7 +95,7 @@ public class TicketPlugin extends JavaPlugin { // Versionsprüfung der config.yml String configVersion = getConfig().getString("version", ""); - String expectedVersion = "2.2"; + String expectedVersion = "2.4"; if (!expectedVersion.equals(configVersion)) { getLogger().warning("[WARNUNG] config.yml-Version (" + configVersion + ") stimmt nicht mit der erwarteten Version (" + expectedVersion + ") überein!"); @@ -149,6 +153,13 @@ public class TicketPlugin extends JavaPlugin { }, ticks, ticks); } + // Web-Panel starten + if (getConfig().getBoolean("web-panel.enabled", false)) { + sessionManager = new SessionManager(this); + webServer = new WebServer(this, sessionManager); + webServer.start(); + } + getLogger().info("TicketSystem v" + getDescription().getVersion() + " erfolgreich gestartet!"); } @@ -157,6 +168,7 @@ public class TicketPlugin extends JavaPlugin { getServer().getMessenger().unregisterOutgoingPluginChannel(this); getServer().getMessenger().unregisterIncomingPluginChannel(this); + if (webServer != null) webServer.stop(); if (ticketCache != null) ticketCache.clear(); if (databaseManager != null) databaseManager.disconnect(); getLogger().info("TicketSystem wurde deaktiviert."); @@ -286,4 +298,6 @@ public class TicketPlugin extends JavaPlugin { public boolean isDebug() { return debug; } public String getServerName() { return serverName; } public boolean isBungeeCordEnabled() { return getConfig().getBoolean("bungeecord", false); } + public SessionManager getSessionManager() { return sessionManager; } + public WebServer getWebServer() { return webServer; } } \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/commands/TicketCommand.java b/src/main/java/de/ticketsystem/commands/TicketCommand.java index 675b853..a68746d 100644 --- a/src/main/java/de/ticketsystem/commands/TicketCommand.java +++ b/src/main/java/de/ticketsystem/commands/TicketCommand.java @@ -2,6 +2,7 @@ package de.ticketsystem.commands; import de.ticketsystem.TicketPlugin; import de.ticketsystem.model.ConfigCategory; +import de.ticketsystem.model.FaqCategory; import de.ticketsystem.model.FaqEntry; import de.ticketsystem.model.Ticket; import de.ticketsystem.model.TicketComment; @@ -56,6 +57,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter { case "bewerten" -> "rate"; case "priorität", "prioritaet" -> "setpriority"; case "hilfe" -> "help"; + case "kategorie" -> "category"; // Englisch + alles andere → unverändert default -> input.toLowerCase(); }; @@ -93,6 +95,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter { case "rate" -> handleRate(player, args); case "setpriority" -> handleSetPriority(player, args); case "faq" -> handleFaq(player, args); + case "category" -> handleFaqCategory(player, args); default -> plugin.getTicketManager().sendHelpMessage(player); } return true; @@ -751,16 +754,39 @@ public class TicketCommand implements CommandExecutor, TabCompleter { player.sendMessage(plugin.lang().get("faq.usage-add")); player.sendMessage(plugin.lang().get("faq.usage-add-example")); return; } - String full = String.join(" ", Arrays.copyOfRange(args, 2, args.length)); + + // Prüfen ob args[2] ein bekannter FAQ-Kategorie-Schlüssel ist + String faqCatKey = null; + int textStart = 2; + if (plugin.getFaqManager().hasCategoriesEnabled()) { + FaqCategory faqCat = plugin.getFaqManager().getCategoryByKey(args[2]); + if (faqCat != null) { + faqCatKey = faqCat.getKey(); + textStart = 3; + } + } + + if (args.length <= textStart) { + player.sendMessage(plugin.lang().get("faq.usage-add")); + player.sendMessage(plugin.lang().get("faq.usage-add-example")); return; + } + + String full = String.join(" ", Arrays.copyOfRange(args, textStart, args.length)); String[] parts = full.split("\\s*\\|\\s*", 2); if (parts.length < 2 || parts[0].isBlank() || parts[1].isBlank()) { player.sendMessage(plugin.lang().get("faq.separator-missing")); player.sendMessage(plugin.lang().get("faq.separator-example")); return; } - FaqEntry created = plugin.getFaqManager().add(parts[0].trim(), parts[1].trim()); + FaqEntry created = plugin.getFaqManager().add(parts[0].trim(), parts[1].trim(), faqCatKey); player.sendMessage(plugin.lang().format("faq.created", "{id}", String.valueOf(created.getId()))); player.sendMessage(plugin.lang().format("faq.created-question", "{question}", created.getQuestion())); player.sendMessage(plugin.lang().format("faq.created-answer", "{answer}", created.getAnswer())); + if (faqCatKey != null) { + FaqCategory cat = plugin.getFaqManager().getCategoryByKey(faqCatKey); + if (cat != null) + player.sendMessage(plugin.lang().format("faq.created-category-info", + "{category}", cat.getColored())); + } } case "edit", "bearbeiten" -> { @@ -837,6 +863,90 @@ public class TicketCommand implements CommandExecutor, TabCompleter { } } + // ── /ticket kategorie (category) ───────────────────────────────────── + // + // /ticket kategorie add [Farbe] [Beschreibung] + // Farbe optional, z.B. &b (Standard: &7) + // Beschreibung optional, alles nach der Farbe + // + // /ticket kategorie delete + // /ticket kategorie list + + private void handleFaqCategory(Player player, String[] args) { + if (!player.hasPermission("ticket.admin")) { + plugin.lang().send(player, "general.no-permission"); return; + } + if (args.length < 2) { + player.sendMessage(plugin.lang().get("faqcat.usage")); return; + } + + switch (args[1].toLowerCase()) { + + case "add", "hinzufügen", "hinzufuegen" -> { + // /ticket kategorie add [&Farbe] [Beschreibung...] + if (args.length < 3) { + player.sendMessage(plugin.lang().get("faqcat.usage-add")); return; + } + String name = args[2]; + String color = "&7"; + String desc = ""; + + if (args.length >= 4 && args[3].startsWith("&")) { + color = args[3]; + if (args.length >= 5) + desc = String.join(" ", Arrays.copyOfRange(args, 4, args.length)); + } else if (args.length >= 4) { + desc = String.join(" ", Arrays.copyOfRange(args, 3, args.length)); + } + + FaqCategory created = plugin.getFaqManager().addCategory( + name.toLowerCase().replaceAll("[^a-z0-9_]", "_"), name, color, desc); + + if (created == null) { + player.sendMessage(plugin.lang().format("faqcat.already-exists", "{name}", name)); + } else { + player.sendMessage(plugin.lang().format("faqcat.created", + "{name}", created.getColored(), + "{key}", created.getKey())); + } + } + + case "delete", "remove", "löschen", "loeschen" -> { + if (args.length < 3) { + player.sendMessage(plugin.lang().get("faqcat.usage-delete")); return; + } + String key = args[2].toLowerCase(); + boolean ok = plugin.getFaqManager().deleteCategory(key); + player.sendMessage(ok + ? plugin.lang().format("faqcat.deleted", "{key}", key) + : plugin.lang().format("faqcat.not-found", "{key}", key)); + } + + case "list", "liste" -> { + var cats = plugin.getFaqManager().getAllCategories(); + player.sendMessage(plugin.lang().get("general.separator")); + player.sendMessage(plugin.lang().format("faqcat.list-header", + "{count}", String.valueOf(cats.size()))); + player.sendMessage(plugin.lang().get("general.separator")); + if (cats.isEmpty()) { + player.sendMessage(plugin.lang().get("faqcat.list-empty")); + } else { + for (FaqCategory cat : cats) { + int count = plugin.getFaqManager().countByCategory(cat.getKey()); + player.sendMessage(plugin.lang().format("faqcat.list-entry", + "{key}", cat.getKey(), + "{name}", cat.getColored(), + "{count}", String.valueOf(count), + "{desc}", cat.getDescription().isEmpty() ? "-" : cat.getDescription())); + } + } + player.sendMessage(plugin.lang().get("general.separator")); + } + + default -> player.sendMessage(plugin.lang().get("faqcat.usage")); + } + } + // ── Hilfsmethoden ───────────────────────────────────────────────────── private Ticket getCachedOrFetch(int ticketId) { @@ -858,6 +968,14 @@ public class TicketCommand implements CommandExecutor, TabCompleter { }; } + private List getFaqCategoryKeysForTab(String input) { + String lower = input == null ? "" : input.toLowerCase(); + List result = new ArrayList<>(); + for (FaqCategory cat : plugin.getFaqManager().getAllCategories()) + if (cat.getKey().startsWith(lower)) result.add(cat.getKey()); + return result; + } + private List getPriorityInputsForTab(String input) { List options = new ArrayList<>(); String lowerInput = input == null ? "" : input.toLowerCase(); @@ -910,6 +1028,11 @@ public class TicketCommand implements CommandExecutor, TabCompleter { if (useDe) subs.addAll(List.of("faq")); } + if (player.hasPermission("ticket.admin")) { + subs.add("kategorie"); + if (useEn) subs.add("category"); + } + if (useEn) subs.add("rate"); if (useDe) subs.add("bewerten"); @@ -928,6 +1051,13 @@ public class TicketCommand implements CommandExecutor, TabCompleter { for (String s : faqSubs) if (s.startsWith(args[1].toLowerCase())) completions.add(s); + } else if (args.length == 3 && normalize(args[0]).equals("faq") + && (args[1].equalsIgnoreCase("add") || args[1].equalsIgnoreCase("hinzufügen") + || args[1].equalsIgnoreCase("hinzufuegen")) + && player.hasPermission("ticket.admin")) { + // /ticket faq add → FAQ-Kategorie-Schlüssel vorschlagen + completions.addAll(getFaqCategoryKeysForTab(args[2])); + } else if (args.length == 3 && normalize(args[0]).equals("faq") && (args[1].equalsIgnoreCase("edit") || args[1].equalsIgnoreCase("delete") || args[1].equalsIgnoreCase("bearbeiten") || args[1].equalsIgnoreCase("löschen")) @@ -935,6 +1065,22 @@ public class TicketCommand implements CommandExecutor, TabCompleter { for (FaqEntry e : plugin.getFaqManager().getAll()) completions.add(String.valueOf(e.getId())); + } else if (args.length == 2 && (normalize(args[0]).equals("category") + || args[0].equalsIgnoreCase("kategorie")) + && player.hasPermission("ticket.admin")) { + // /ticket kategorie + if (useEn) completions.addAll(List.of("add", "delete", "list")); + if (useDe) completions.addAll(List.of("hinzufügen", "löschen", "liste")); + completions.removeIf(s -> !s.startsWith(args[1].toLowerCase())); + + } else if (args.length == 3 && (normalize(args[0]).equals("category") + || args[0].equalsIgnoreCase("kategorie")) + && (args[1].equalsIgnoreCase("delete") || args[1].equalsIgnoreCase("remove") + || args[1].equalsIgnoreCase("löschen") || args[1].equalsIgnoreCase("loeschen")) + && player.hasPermission("ticket.admin")) { + // /ticket kategorie delete → vorhandene Schlüssel + completions.addAll(getFaqCategoryKeysForTab(args[2])); + } else if (args.length == 2 && normalize(args[0]).equals("create")) { boolean categoriesOn = plugin.getConfig().getBoolean("categories-enabled", true); boolean prioritiesOn = plugin.getConfig().getBoolean("priorities-enabled", true); diff --git a/src/main/java/de/ticketsystem/gui/FaqGUI.java b/src/main/java/de/ticketsystem/gui/FaqGUI.java index 84afc83..20f7b00 100644 --- a/src/main/java/de/ticketsystem/gui/FaqGUI.java +++ b/src/main/java/de/ticketsystem/gui/FaqGUI.java @@ -1,6 +1,7 @@ package de.ticketsystem.gui; import de.ticketsystem.TicketPlugin; +import de.ticketsystem.model.FaqCategory; import de.ticketsystem.model.FaqEntry; import org.bukkit.Bukkit; import org.bukkit.Material; @@ -23,204 +24,287 @@ import java.util.*; /** * FAQ GUI für Spieler (Lesemodus) und Admins (Verwaltungsmodus). - * Layout, Größe und Slots sind über config.yml steuerbar. + * + * ── Kategorie-Modus (Kategorien in faqs.yml definiert) ─────────────────────── + * openFaqGUI() → Kategorie-Auswahl-Screen + * Spieler: Klick → gefilterte FAQ-Liste + * Admin: Klick → gefilterte FAQ-Liste | Shift+Klick → Kategorie-Aktions-GUI + * → Zurück-Button → zurück zur Auswahl + * + * ── Legacy-Modus (keine Kategorien) ───────────────────────────────────────── + * openFaqGUI() → direkt die komplette FAQ-Liste + * + * Admins können Kategorien in-game hinzufügen, bearbeiten und löschen. */ public class FaqGUI implements Listener { // ── Konfigurierbare Felder ──────────────────────────────────────────────── - private int faqRows; - private int faqNavPrev, faqNavNext, faqNavAdd, faqNavPage; - - private Material headMaterial = Material.PLAYER_HEAD; - private String headTexture = "http://textures.minecraft.net/texture/da2fde34d34c8588e58bfd790ce18025f7843399dee2ab4cedc2c0b463fd1e"; + private int faqRows; + private int faqNavPrev, faqNavNext, faqNavAdd, faqNavPage; + private Material headMaterial = Material.PLAYER_HEAD; + private String headTexture = DEFAULT_SKIN_URL; + private Material catHeadMaterial = Material.PLAYER_HEAD; + private String catHeadTexture = DEFAULT_CAT_SKIN_URL; private List contentSlots = new ArrayList<>(); + private Material matNavPrev, matNavNext, matNavPage, matNavAdd; - // ── System Felder ──────────────────────────────────────────────────────── private static final String DEFAULT_SKIN_URL = "http://textures.minecraft.net/texture/da2fde34d34c8588e58bfd790ce18025f7843399dee2ab4cedc2c0b463fd1e"; + private static final String DEFAULT_CAT_SKIN_URL = + "http://textures.minecraft.net/texture/2867f1d66d76bb2952e7a82f0dcf8eac5ae0e035a646db07a72b2d668df7cff2"; private final TicketPlugin plugin; - private final Map> slotMap = new HashMap<>(); - private final Map faqPage = new HashMap<>(); - private final Set adminView = new HashSet<>(); - private final Map actionEntry = new HashMap<>(); + // ── State-Maps ─────────────────────────────────────────────────────────── + private final Map> slotMap = new HashMap<>(); + private final Map> catSlotMap = new HashMap<>(); + private final Map faqPage = new HashMap<>(); + private final Set adminView = new HashSet<>(); + private final Map actionEntry = new HashMap<>(); + private final Map activeCategory = new HashMap<>(); + /** Kategorie die gerade im Aktions-GUI bearbeitet wird */ + private final Map catAction = new HashMap<>(); - private final Map awaitingQuestion = new HashMap<>(); - private final Map awaitingAnswer = new HashMap<>(); + // ── Chat-Flow-Maps ─────────────────────────────────────────────────────── + private final Map awaitingQuestion = new HashMap<>(); + private final Map awaitingAnswer = new HashMap<>(); + private final Map addFlowCategory = new HashMap<>(); - // Materialien für Navigations-Buttons - private Material matNavPrev, matNavNext, matNavPage, matNavAdd; + /** + * Chat-Flow für Kategorie-Verwaltung. + * Zustände: "cat_name", "cat_color:", "cat_desc::", + * "cat_edit_name:", "cat_edit_color::", "cat_edit_desc:::" + */ + private final Map awaitingCatInput = new HashMap<>(); public FaqGUI(TicketPlugin plugin) { this.plugin = plugin; reloadConfig(); } - /** - * Berechnet die Item-Slots: - * Schachbrett-Muster ohne Glas – passt sich automatisch an jede Größe an: - * - * Reihe 0 : Glas in geraden Spalten (0,2,4,6,8), Items in ungeraden (1,3,5,7) → 4 Items - * Reihe 1 : Items in geraden Spalten (0,2,4,6,8), leer in ungeraden → 5 Items - * Reihe 2 : Items in ungeraden Spalten (1,3,5,7), leer in geraden → 4 Items - * Reihe 3 : Items in geraden Spalten (0,2,4,6,8), leer in ungeraden → 5 Items - * ... (Reihe 1+ kein Glas – nur Items und leere Slots wechselnd) - * Letzte Reihe: Navigation (Footer) - * - * Kapazität: - * 4 Reihen → 13 Items | 5 Reihen → 18 Items | 6 Reihen → 22 Items - */ + // ═══════════════════════════════════════════════════════════════════════ + // CONFIG + // ═══════════════════════════════════════════════════════════════════════ + private List buildPatternSlots(int rows) { List slots = new ArrayList<>(); - int contentRows = rows - 1; // Letzte Reihe = Navigation + int contentRows = rows - 1; for (int row = 0; row < contentRows; row++) { int base = row * 9; if (row % 2 == 0) { - // Gerade Reihen (0,2,4,...): Items in geraden Spalten - slots.add(base + 0); - slots.add(base + 2); - slots.add(base + 4); - slots.add(base + 6); - slots.add(base + 8); + slots.add(base); slots.add(base + 2); slots.add(base + 4); + slots.add(base + 6); slots.add(base + 8); } else { - // Ungerade Reihen (1,3,5,...): Items in ungeraden Spalten - slots.add(base + 1); - slots.add(base + 3); - slots.add(base + 5); - slots.add(base + 7); + slots.add(base + 1); slots.add(base + 3); + slots.add(base + 5); slots.add(base + 7); } } return slots; } - /** - * Setzt die Navigations-Slot-Defaults passend zur aktuellen Zeilenanzahl. - * Nav-Leiste liegt immer in der letzten Reihe. - */ - /** - * Nav-Leiste liegt in der letzten Reihe. - * Gerade Spalten werden durch fillNavBar bereits mit Glas gefüllt. - * Buttons liegen auf ungerade Spalten – passend zum Content-Muster: - * Slot +1 = Prev | Slot +3 = Add | Slot +5 = Page | Slot +7 = Next - */ private void applyNavDefaults(int rows) { - int navBase = (rows - 1) * 9; // Erster Slot der letzten Reihe - faqNavPrev = navBase + 1; // 2. Slot (ungerade) – Blättern zurück - faqNavAdd = navBase + 3; // 4. Slot (ungerade) – Hinzufügen - faqNavPage = navBase + 5; // 6. Slot (ungerade) – Seitenanzeige - faqNavNext = navBase + 7; // 8. Slot (ungerade) – Blättern vor + int navBase = (rows - 1) * 9; + faqNavPrev = navBase + 1; + faqNavAdd = navBase + 3; + faqNavPage = navBase + 5; + faqNavNext = navBase + 7; } - /** - * Lädt die Konfiguration sicher mit Fallback-Werten. - */ public void reloadConfig() { - // ── 1. STANDARDWERTE SETZEN (Sicherheit gegen leere Config) ───────── - faqRows = 6; - - headMaterial = Material.PLAYER_HEAD; - headTexture = DEFAULT_SKIN_URL; - - matNavPrev = Material.ARROW; - matNavNext = Material.ARROW; - matNavPage = Material.PAPER; - matNavAdd = Material.LIME_WOOL; - - // Standard Content-Slots nach Muster: Glasscheiben in Spalte 0, 4, 8 - contentSlots = buildPatternSlots(faqRows); + faqRows = 6; + headMaterial = Material.PLAYER_HEAD; + headTexture = DEFAULT_SKIN_URL; + catHeadMaterial = Material.PLAYER_HEAD; + catHeadTexture = DEFAULT_CAT_SKIN_URL; + matNavPrev = Material.ARROW; + matNavNext = Material.ARROW; + matNavPage = Material.PAPER; + matNavAdd = Material.LIME_WOOL; + contentSlots = buildPatternSlots(faqRows); applyNavDefaults(faqRows); - // ── 2. VERSUCHEN AUS CONFIG ZU LADEN ───────────────────────────────── - ConfigurationSection guiSettings = plugin.getConfig().getConfigurationSection("gui-settings"); + ConfigurationSection gs = plugin.getConfig().getConfigurationSection("gui-settings"); + if (gs == null) return; - if (guiSettings != null) { - ConfigurationSection faqConf = guiSettings.getConfigurationSection("faq"); - if (faqConf != null) { - // Rows laden - faqRows = faqConf.getInt("rows", 6); - if (faqRows < 4) faqRows = 4; // Minimum 4 Reihen - if (faqRows > 6) faqRows = 6; + ConfigurationSection fc = gs.getConfigurationSection("faq"); + if (fc != null) { + faqRows = Math.max(4, Math.min(6, fc.getInt("rows", 6))); + applyNavDefaults(faqRows); + if (fc.contains("content-slots") && !fc.getIntegerList("content-slots").isEmpty()) + contentSlots = fc.getIntegerList("content-slots"); + else + contentSlots = buildPatternSlots(faqRows); - // Nav-Defaults für die gewählte Zeilenanzahl setzen - applyNavDefaults(faqRows); + faqNavPrev = getSlot(fc, "nav.prev", faqNavPrev, faqRows); + faqNavNext = getSlot(fc, "nav.next", faqNavNext, faqRows); + faqNavPage = getSlot(fc, "nav.page", faqNavPage, faqRows); + faqNavAdd = getSlot(fc, "nav.add", faqNavAdd, faqRows); - // Content Slots laden – nur überschreiben wenn explizit in config gesetzt - if (faqConf.contains("content-slots") && !faqConf.getIntegerList("content-slots").isEmpty()) { - contentSlots = faqConf.getIntegerList("content-slots"); - } else { - // Muster für die konfigurierte Zeilenanzahl neu berechnen - contentSlots = buildPatternSlots(faqRows); - } - - // Navigation Slots laden (Config überschreibt dynamische Defaults) - faqNavPrev = getSlot(faqConf, "nav.prev", faqNavPrev, faqRows); - faqNavNext = getSlot(faqConf, "nav.next", faqNavNext, faqRows); - faqNavPage = getSlot(faqConf, "nav.page", faqNavPage, faqRows); - faqNavAdd = getSlot(faqConf, "nav.add", faqNavAdd, faqRows); - - // Head Config laden - ConfigurationSection headConf = faqConf.getConfigurationSection("head-item"); - if (headConf != null) { - headMaterial = getMaterial(headConf, "material", Material.PLAYER_HEAD); - headTexture = headConf.getString("texture", DEFAULT_SKIN_URL); - } + ConfigurationSection hc = fc.getConfigurationSection("head-item"); + if (hc != null) { + headMaterial = getMat(hc, "material", Material.PLAYER_HEAD); + headTexture = hc.getString("texture", DEFAULT_SKIN_URL); } - - // Materialien laden (Global) - ConfigurationSection itemsSettings = guiSettings.getConfigurationSection("items"); - if (itemsSettings != null) { - matNavPrev = getMaterial(itemsSettings, "nav-prev", Material.ARROW); - matNavNext = getMaterial(itemsSettings, "nav-next", Material.ARROW); - matNavPage = getMaterial(itemsSettings, "nav-page", Material.PAPER); - matNavAdd = getMaterial(itemsSettings, "nav-add", Material.LIME_WOOL); + ConfigurationSection cc = fc.getConfigurationSection("category-head-item"); + if (cc != null) { + catHeadMaterial = getMat(cc, "material", Material.PLAYER_HEAD); + String tex = cc.getString("texture", ""); + catHeadTexture = tex.isBlank() ? DEFAULT_CAT_SKIN_URL : tex; } } + + ConfigurationSection is = gs.getConfigurationSection("items"); + if (is != null) { + matNavPrev = getMat(is, "nav-prev", Material.ARROW); + matNavNext = getMat(is, "nav-next", Material.ARROW); + matNavPage = getMat(is, "nav-page", Material.PAPER); + matNavAdd = getMat(is, "nav-add", Material.LIME_WOOL); + } } - private int getSlot(ConfigurationSection section, String path, int def, int rows) { - int val = section.getInt(path, def); - int max = rows * 9; + private int getSlot(ConfigurationSection s, String p, int def, int rows) { + int val = s.getInt(p, def), max = rows * 9; if (val >= 0 && val < max) return val; - // Slot liegt außerhalb des Inventars → Spalte beibehalten, letzte Reihe verwenden - int col = (val >= 0 ? val : def) % 9; - return (rows - 1) * 9 + col; + return (rows - 1) * 9 + (val >= 0 ? val : def) % 9; } - private Material getMaterial(ConfigurationSection section, String path, Material def) { - try { - return Material.valueOf(section.getString(path, def.name())); - } catch (IllegalArgumentException e) { - return def; - } + private Material getMat(ConfigurationSection s, String p, Material def) { + try { return Material.valueOf(s.getString(p, def.name())); } + catch (IllegalArgumentException e) { return def; } } - // ── Hilfsmethode für Sprachzugriff ───────────────────────────────────────── - private String f(String key) { - return plugin.lang().get("gui.faq." + key); - } - - private String f(String key, String... replacements) { - return plugin.lang().format("gui.faq." + key, replacements); - } + // ── Lang ────────────────────────────────────────────────────────────── + private String f(String key) { return plugin.lang().get("gui.faq." + key); } + private String f(String key, String... r) { return plugin.lang().format("gui.faq." + key, r); } // ═══════════════════════════════════════════════════════════════════════ - // PUBLIC OPEN-METHODEN + // EINSTIEGSPUNKT // ═══════════════════════════════════════════════════════════════════════ public void openFaqGUI(Player player) { - openFaqGUI(player, faqPage.getOrDefault(player.getUniqueId(), 0)); + if (plugin.getFaqManager().hasCategoriesEnabled()) + openCategoryScreen(player); + else + openFaqList(player, null, faqPage.getOrDefault(player.getUniqueId(), 0)); } - public void openFaqGUI(Player player, int page) { - boolean isAdmin = player.hasPermission("ticket.admin"); - String title = isAdmin ? f("admin-title") : f("title"); + // ═══════════════════════════════════════════════════════════════════════ + // KATEGORIE-AUSWAHL-SCREEN + // ═══════════════════════════════════════════════════════════════════════ - List all = plugin.getFaqManager().getAll(); - - // Sicherheit gegen leere Content-Slots Liste (Division by Zero) - int pageSize = contentSlots.isEmpty() ? 45 : contentSlots.size(); - + public void openCategoryScreen(Player player) { + boolean isAdmin = player.hasPermission("ticket.admin"); + String title = isAdmin ? f("cat-admin-title") : f("cat-title"); + + List cats = plugin.getFaqManager().getAllCategories(); + int totalItems = cats.size(); + int rows = Math.max(3, Math.min(6, (int) Math.ceil((totalItems + 2) / 9.0) + 1)); + Inventory inv = Bukkit.createInventory(null, rows * 9, title); + + Map csm = new HashMap<>(); + List cSlots = buildCategorySlots(rows, totalItems); + + for (int i = 0; i < cats.size() && i < cSlots.size(); i++) { + FaqCategory cat = cats.get(i); + int count = plugin.getFaqManager().countByCategory(cat.getKey()); + inv.setItem(cSlots.get(i), buildCategoryItem(cat, count, isAdmin)); + csm.put(cSlots.get(i), cat); + } + + catSlotMap.put(player.getUniqueId(), csm); + + if (isAdmin) adminView.add(player.getUniqueId()); + else adminView.remove(player.getUniqueId()); + + fillGlass(inv); + player.openInventory(inv); + } + + private List buildCategorySlots(int rows, int count) { + List slots = new ArrayList<>(); + int[] cols = {2, 4, 6}; + int contentRows = rows - 1; + for (int row = 0; row < contentRows && slots.size() < count; row++) + for (int col : cols) { if (slots.size() >= count) break; slots.add(row * 9 + col); } + return slots; + } + + private ItemStack buildCategoryItem(FaqCategory cat, int count, boolean isAdmin) { + String name = cat.getColored() + " §8§l▶"; + List lore = buildCategoryLore(cat, count, isAdmin); + if (catHeadMaterial == Material.PLAYER_HEAD) + return buildSkull("CAT_" + cat.getKey(), catHeadTexture, name, lore); + ItemStack item = new ItemStack(catHeadMaterial); + applyMeta(item, name, lore); + return item; + } + + private List buildCategoryLore(FaqCategory cat, int count, boolean isAdmin) { + List lore = new ArrayList<>(); + if (!cat.getDescription().isEmpty()) lore.add("§7" + cat.getDescription()); + lore.add(f("cat-lore-separator")); + lore.add(f("cat-lore-count", "{count}", String.valueOf(count))); + lore.add(f("cat-lore-separator")); + lore.add(f("cat-lore-click")); + if (isAdmin) { + lore.add(f("cat-lore-admin-hint")); + lore.add(f("cat-lore-shift-hint")); + } + return lore; + } + + // ─────────────────────────── Kategorie-Aktions-GUI ───────────────────── + + /** + * Admin-GUI für eine bestehende Kategorie: Bearbeiten / Löschen / Zurück. + */ + private void openCategoryActionGUI(Player player, FaqCategory cat) { + catAction.put(player.getUniqueId(), cat); + Inventory inv = Bukkit.createInventory(null, 27, f("cat-action-title")); + + // Kategorie-Vorschau (Slot 4) + inv.setItem(4, buildCategoryItem(cat, + plugin.getFaqManager().countByCategory(cat.getKey()), false)); + + // Bearbeiten (Slot 10) + inv.setItem(10, buildItem(Material.WRITABLE_BOOK, + f("cat-edit-button"), List.of(f("cat-edit-lore-1"), f("cat-edit-lore-2")))); + + // Löschen (Slot 12) + inv.setItem(12, buildItem(Material.BARRIER, + f("cat-delete-button"), List.of(f("cat-delete-lore-1"), f("cat-delete-lore-2")))); + + // Zurück (Slot 16) + inv.setItem(16, buildItem(Material.ARROW, + f("cat-back-button"), List.of(f("cat-back-lore")))); + + fillGlass(inv); + player.openInventory(inv); + } + + // ═══════════════════════════════════════════════════════════════════════ + // FAQ-LISTE + // ═══════════════════════════════════════════════════════════════════════ + + public void openFaqList(Player player, String categoryKey, int page) { + activeCategory.put(player.getUniqueId(), categoryKey); + boolean isAdmin = player.hasPermission("ticket.admin"); + + String title; + if (categoryKey != null && plugin.getFaqManager().hasCategoriesEnabled()) { + FaqCategory cat = plugin.getFaqManager().getCategoryByKey(categoryKey); + String catName = cat != null ? cat.getColored() : "§f" + categoryKey; + title = isAdmin ? f("admin-title-cat", "{category}", catName) + : f("title-cat", "{category}", catName); + } else { + title = isAdmin ? f("admin-title") : f("title"); + } + + List all = (categoryKey != null) + ? plugin.getFaqManager().getByCategory(categoryKey) + : plugin.getFaqManager().getAll(); + + int pageSize = contentSlots.isEmpty() ? 45 : contentSlots.size(); int totalPages = Math.max(1, (int) Math.ceil((double) all.size() / pageSize)); page = Math.max(0, Math.min(page, totalPages - 1)); faqPage.put(player.getUniqueId(), page); @@ -228,31 +312,20 @@ public class FaqGUI implements Listener { int invSize = faqRows * 9; Inventory inv = Bukkit.createInventory(null, invSize, title); Map sm = new HashMap<>(); + int start = page * pageSize, itemCount = 0; - int start = page * pageSize; - int itemsOnCurrentPage = 0; // Zähler für Items auf dieser Seite - - // Wir nutzen entweder die konfigurierten Slots oder sequentiell von 0, falls Liste leer for (int i = 0; i < pageSize && (start + i) < all.size(); i++) { FaqEntry entry = all.get(start + i); - int slot = contentSlots.isEmpty() ? i : contentSlots.get(i); - - if (slot < invSize) { - inv.setItem(slot, buildFaqItem(entry, isAdmin)); - sm.put(slot, entry); - itemsOnCurrentPage++; - } + int slot = contentSlots.isEmpty() ? i : contentSlots.get(i); + if (slot < invSize) { inv.setItem(slot, buildFaqItem(entry, isAdmin)); sm.put(slot, entry); itemCount++; } } slotMap.put(player.getUniqueId(), sm); - if (isAdmin) adminView.add(player.getUniqueId()); else adminView.remove(player.getUniqueId()); - // Kein Glas im Content-Bereich – nur Footer (fillNavBar) hat Glasscheiben - - // Übergeben der korrekten Anzahl an die Navigationsleiste - fillNavBar(inv, page, totalPages, isAdmin, all.isEmpty(), itemsOnCurrentPage); + fillNavBar(inv, page, totalPages, isAdmin, all.isEmpty(), itemCount, + plugin.getFaqManager().hasCategoriesEnabled()); player.openInventory(inv); } @@ -265,45 +338,81 @@ public class FaqGUI implements Listener { if (!(event.getWhoClicked() instanceof Player player)) return; String title = event.getView().getTitle(); - String playerTitle = f("title"); - String adminTitle = f("admin-title"); - String actionTitle = f("action-title"); + boolean isCatScreen = title.equals(f("cat-title")) || title.equals(f("cat-admin-title")); + boolean isCatActionGui = title.equals(f("cat-action-title")); + boolean isFaqList = title.equals(f("title")) || title.equals(f("admin-title")) + || startsWithAny(title, f("title-cat-prefix"), f("admin-title-cat-prefix")); + boolean isActionGui = title.equals(f("action-title")); - if (!title.equals(playerTitle) && !title.equals(adminTitle) - && !title.equals(actionTitle)) return; + if (!isCatScreen && !isCatActionGui && !isFaqList && !isActionGui) return; event.setCancelled(true); int slot = event.getRawSlot(); if (slot < 0) return; - if (title.equals(actionTitle)) { + // ── Kategorie-Aktions-GUI ──────────────────────────────────────── + if (isCatActionGui) { + FaqCategory cat = catAction.get(player.getUniqueId()); + if (cat == null) return; + switch (slot) { + case 10 -> startCategoryEditFlow(player, cat); + case 12 -> deleteCategoryAction(player, cat); + case 16 -> openCategoryScreen(player); + } + return; + } + + // ── FAQ-Aktions-GUI ────────────────────────────────────────────── + if (isActionGui) { FaqEntry entry = actionEntry.get(player.getUniqueId()); if (entry == null) return; switch (slot) { case 10 -> startEditFlow(player, entry); case 12 -> deleteFaq(player, entry); - case 16 -> openFaqGUI(player); + case 16 -> reopenAfterAction(player); } return; } + // ── Kategorie-Screen ───────────────────────────────────────────── + if (isCatScreen) { + Map csm = catSlotMap.get(player.getUniqueId()); + if (csm == null || !csm.containsKey(slot)) return; + + FaqCategory cat = csm.get(slot); + if (cat == null) return; + + boolean isAdmin = adminView.contains(player.getUniqueId()); + // Admin + Shift-Klick → Aktions-GUI für diese Kategorie + if (isAdmin && event.isShiftClick()) { + openCategoryActionGUI(player, cat); + } else { + faqPage.remove(player.getUniqueId()); + openFaqList(player, cat.getKey(), 0); + } + return; + } + + // ── FAQ-Listen-Screen ───────────────────────────────────────────── boolean isAdmin = adminView.contains(player.getUniqueId()); - int curPage = faqPage.getOrDefault(player.getUniqueId(), 0); + int curPage = faqPage.getOrDefault(player.getUniqueId(), 0); + String curCat = activeCategory.get(player.getUniqueId()); - if (slot == faqNavPrev) { openFaqGUI(player, curPage - 1); return; } - if (slot == faqNavNext) { openFaqGUI(player, curPage + 1); return; } - if (slot == faqNavAdd && isAdmin) { startAddFlow(player); return; } + if (plugin.getFaqManager().hasCategoriesEnabled() && slot == getBackButtonSlot()) { + openCategoryScreen(player); + return; + } + if (slot == faqNavPrev) { openFaqList(player, curCat, curPage - 1); return; } + if (slot == faqNavNext) { openFaqList(player, curCat, curPage + 1); return; } + if (slot == faqNavAdd && isAdmin) { startAddFlow(player, curCat); return; } - // Check ob Slot in contentSlots ist if (contentSlots.contains(slot)) { Map sm = slotMap.get(player.getUniqueId()); if (sm == null) return; FaqEntry entry = sm.get(slot); if (entry == null) return; - - if (isAdmin) { - openActionGUI(player, entry); - } else { + if (isAdmin) openActionGUI(player, entry); + else { player.closeInventory(); player.sendMessage(plugin.lang().get("general.separator")); player.sendMessage(plugin.lang().format("faq.list-entry", @@ -314,58 +423,96 @@ public class FaqGUI implements Listener { } } - // ─────────────────────────── Admin-Aktions-GUI ───────────────────────── + private boolean startsWithAny(String title, String... prefixes) { + for (String p : prefixes) if (p != null && !p.isBlank() && title.startsWith(p)) return true; + return false; + } + + private int getBackButtonSlot() { return (faqRows - 1) * 9; } + + // ─────────────────────────── FAQ-Aktions-GUI ─────────────────────────── private void openActionGUI(Player player, FaqEntry entry) { actionEntry.put(player.getUniqueId(), entry); Inventory inv = Bukkit.createInventory(null, 27, f("action-title")); - inv.setItem(4, buildFaqItem(entry, false)); - inv.setItem(10, buildItem(Material.WRITABLE_BOOK, f("edit-button"), - List.of(f("edit-lore-1"), f("edit-lore-2")))); - inv.setItem(12, buildItem(Material.BARRIER, f("delete-button"), - List.of(f("delete-lore-1"), f("delete-lore-2")))); - inv.setItem(16, buildItem(Material.ARROW, f("back-button"), - List.of(f("back-lore")))); - + inv.setItem(10, buildItem(Material.WRITABLE_BOOK, f("edit-button"), List.of(f("edit-lore-1"), f("edit-lore-2")))); + inv.setItem(12, buildItem(Material.BARRIER, f("delete-button"), List.of(f("delete-lore-1"), f("delete-lore-2")))); + inv.setItem(16, buildItem(Material.ARROW, f("back-button"), List.of(f("back-lore")))); fillGlass(inv); player.openInventory(inv); } - // ─────────────────────────── Chat-Flow: Hinzufügen ───────────────────── + private void reopenAfterAction(Player player) { + if (plugin.getFaqManager().hasCategoriesEnabled()) { + String cat = activeCategory.get(player.getUniqueId()); + if (cat != null) openFaqList(player, cat, faqPage.getOrDefault(player.getUniqueId(), 0)); + else openCategoryScreen(player); + } else { + openFaqList(player, null, faqPage.getOrDefault(player.getUniqueId(), 0)); + } + } - private void startAddFlow(Player player) { + // ─────────────────────────── Chat-Flow: FAQ ──────────────────────────── + + private void startAddFlow(Player player, String categoryKey) { player.closeInventory(); + addFlowCategory.put(player.getUniqueId(), categoryKey != null ? categoryKey : "__none__"); awaitingQuestion.put(player.getUniqueId(), "new"); player.sendMessage(plugin.lang().get("general.separator")); player.sendMessage(f("chat-create-title")); + if (categoryKey != null && plugin.getFaqManager().hasCategoriesEnabled()) { + FaqCategory cat = plugin.getFaqManager().getCategoryByKey(categoryKey); + if (cat != null) player.sendMessage(f("chat-category-hint", "{category}", cat.getColored())); + } player.sendMessage(f("chat-question-prompt")); player.sendMessage(plugin.lang().get("general.separator")); } - // ─────────────────────────── Chat-Flow: Bearbeiten ───────────────────── - private void startEditFlow(Player player, FaqEntry entry) { player.closeInventory(); awaitingQuestion.put(player.getUniqueId(), "edit:" + entry.getId()); player.sendMessage(plugin.lang().get("general.separator")); - player.sendMessage(f("chat-edit-title", "{id}", String.valueOf(entry.getId()))); + player.sendMessage(f("chat-edit-title", "{id}", String.valueOf(entry.getId()))); player.sendMessage(f("chat-current-question", "{question}", entry.getQuestion())); player.sendMessage(f("chat-question-prompt")); player.sendMessage(plugin.lang().get("general.separator")); } - // ─────────────────────────── Löschen ─────────────────────────────────── - private void deleteFaq(Player player, FaqEntry entry) { player.closeInventory(); - boolean success = plugin.getFaqManager().delete(entry.getId()); - if (success) { - player.sendMessage(plugin.lang().format("faq.deleted", "{id}", String.valueOf(entry.getId()))); - } else { - player.sendMessage(f("delete-error", "{id}", String.valueOf(entry.getId()))); - } - openFaqGUI(player); + boolean ok = plugin.getFaqManager().delete(entry.getId()); + player.sendMessage(ok + ? plugin.lang().format("faq.deleted", "{id}", String.valueOf(entry.getId())) + : f("delete-error", "{id}", String.valueOf(entry.getId()))); + reopenAfterAction(player); + } + + // ─────────────────────────── Chat-Flow: Kategorie ───────────────────── + + /** + * Startet den Chat-Flow zum Bearbeiten einer bestehenden Kategorie. + * Schritt 1: Neuen Namen eingeben + */ + private void startCategoryEditFlow(Player player, FaqCategory cat) { + player.closeInventory(); + awaitingCatInput.put(player.getUniqueId(), "cat_edit_name:" + cat.getKey()); + player.sendMessage(plugin.lang().get("general.separator")); + player.sendMessage(f("cat-chat-edit-title", "{name}", cat.getColored())); + player.sendMessage(f("cat-chat-current-name", "{name}", cat.getName())); + player.sendMessage(f("cat-chat-current-color", "{color}", cat.getColor() + cat.getColor())); + player.sendMessage(f("cat-chat-current-desc", "{desc}", cat.getDescription())); + player.sendMessage(f("cat-chat-name-prompt")); + player.sendMessage(plugin.lang().get("general.separator")); + } + + private void deleteCategoryAction(Player player, FaqCategory cat) { + player.closeInventory(); + boolean ok = plugin.getFaqManager().deleteCategory(cat.getKey()); + player.sendMessage(ok + ? f("cat-deleted", "{name}", cat.getColored()) + : f("cat-not-found", "{name}", cat.getName())); + openCategoryScreen(player); } // ═══════════════════════════════════════════════════════════════════════ @@ -377,13 +524,79 @@ public class FaqGUI implements Listener { Player player = event.getPlayer(); UUID uuid = player.getUniqueId(); - // ── Schritt 1: Warte auf Frage ───────────────────────────────────── + // ── Kategorie-Chat-Flow ────────────────────────────────────────── + if (awaitingCatInput.containsKey(uuid)) { + event.setCancelled(true); + String state = awaitingCatInput.get(uuid); + String input = event.getMessage().trim(); + + if (input.equalsIgnoreCase("cancel")) { + awaitingCatInput.remove(uuid); + Bukkit.getScheduler().runTask(plugin, () -> { + player.sendMessage(plugin.lang().get("gui.close-cancelled")); + openCategoryScreen(player); + }); + return; + } + + // ── Schritt: Name (bearbeiten) ─────────────────────────────── + if (state.startsWith("cat_edit_name:")) { + String key = state.substring("cat_edit_name:".length()); + awaitingCatInput.put(uuid, "cat_edit_color:" + key + ":" + input); + Bukkit.getScheduler().runTask(plugin, () -> { + player.sendMessage(f("cat-chat-name-set", "{name}", input)); + player.sendMessage(f("cat-chat-color-prompt")); + }); + return; + } + + // ── Schritt: Farbe (bearbeiten) ────────────────────────────── + if (state.startsWith("cat_edit_color:")) { + String rest = state.substring("cat_edit_color:".length()); + int sep = rest.indexOf(':'); + String key = rest.substring(0, sep); + String name = rest.substring(sep + 1); + awaitingCatInput.put(uuid, "cat_edit_desc:" + key + ":" + name + ":" + input); + Bukkit.getScheduler().runTask(plugin, () -> { + player.sendMessage(f("cat-chat-color-set", + "{colored}", plugin.lang().color(input + name))); + player.sendMessage(f("cat-chat-desc-prompt")); + }); + return; + } + + // ── Schritt: Beschreibung (bearbeiten) – abschließend ──────── + if (state.startsWith("cat_edit_desc:")) { + String rest = state.substring("cat_edit_desc:".length()); + String[] p = rest.split(":", 3); + String key = p[0]; + String name = p[1]; + String color = p[2]; + String desc = input.equals("-") ? "" : input; + awaitingCatInput.remove(uuid); + Bukkit.getScheduler().runTask(plugin, () -> { + boolean ok = plugin.getFaqManager().editCategory(key, name, color, desc); + player.sendMessage(ok + ? f("cat-updated", "{name}", plugin.lang().color(color + name)) + : f("cat-not-found", "{name}", name)); + openCategoryScreen(player); + }); + return; + } + + // Unbekannter State → reset + awaitingCatInput.remove(uuid); + return; + } + + // ── FAQ-Frage-Flow ─────────────────────────────────────────────── if (awaitingQuestion.containsKey(uuid)) { event.setCancelled(true); String state = awaitingQuestion.remove(uuid); String input = event.getMessage().trim(); if (input.equalsIgnoreCase("cancel")) { + addFlowCategory.remove(uuid); Bukkit.getScheduler().runTask(plugin, () -> { player.sendMessage(plugin.lang().get("gui.close-cancelled")); openFaqGUI(player); @@ -392,21 +605,19 @@ public class FaqGUI implements Listener { } awaitingAnswer.put(uuid, state + "\u0000" + input); - - Bukkit.getScheduler().runTask(plugin, () -> { - player.sendMessage(f("question-set", "{question}", input)); - player.sendMessage(f("chat-answer-prompt")); - }); + Bukkit.getScheduler().runTask(plugin, () -> player.sendMessage(f("question-set", "{question}", input))); + Bukkit.getScheduler().runTask(plugin, () -> player.sendMessage(f("chat-answer-prompt"))); return; } - // ── Schritt 2: Warte auf Antwort ─────────────────────────────────── + // ── FAQ-Antwort-Flow ───────────────────────────────────────────── if (awaitingAnswer.containsKey(uuid)) { event.setCancelled(true); String stateAndQuestion = awaitingAnswer.remove(uuid); String input = event.getMessage().trim(); if (input.equalsIgnoreCase("cancel")) { + addFlowCategory.remove(uuid); Bukkit.getScheduler().runTask(plugin, () -> { player.sendMessage(plugin.lang().get("gui.close-cancelled")); openFaqGUI(player); @@ -417,25 +628,22 @@ public class FaqGUI implements Listener { int sep = stateAndQuestion.indexOf("\u0000"); String state = stateAndQuestion.substring(0, sep); String question = stateAndQuestion.substring(sep + 1); + String catKey = addFlowCategory.remove(uuid); Bukkit.getScheduler().runTask(plugin, () -> { if (state.equals("new")) { - FaqEntry created = plugin.getFaqManager().add(question, input); + FaqEntry created = plugin.getFaqManager().add(question, input, catKey); player.sendMessage(plugin.lang().format("faq.created", "{id}", String.valueOf(created.getId()))); } else { int id; - try { - id = Integer.parseInt(state.substring(5)); - } catch (NumberFormatException e) { - player.sendMessage(f("internal-error")); - openFaqGUI(player); - return; - } + try { id = Integer.parseInt(state.substring(5)); } + catch (NumberFormatException e) { player.sendMessage(f("internal-error")); openFaqGUI(player); return; } boolean ok = plugin.getFaqManager().edit(id, question, input); - if (ok) player.sendMessage(plugin.lang().format("faq.updated", "{id}", String.valueOf(id))); - else player.sendMessage(plugin.lang().format("faq.not-found", "{id}", String.valueOf(id))); + player.sendMessage(ok + ? plugin.lang().format("faq.updated", "{id}", String.valueOf(id)) + : plugin.lang().format("faq.not-found", "{id}", String.valueOf(id))); } - openFaqGUI(player); + reopenAfterAction(player); }); } } @@ -445,46 +653,12 @@ public class FaqGUI implements Listener { // ═══════════════════════════════════════════════════════════════════════ private ItemStack buildFaqItem(FaqEntry entry, boolean adminHint) { - ItemStack item; - - if (headMaterial == Material.PLAYER_HEAD) { - try { - item = new ItemStack(Material.PLAYER_HEAD); - SkullMeta meta = (SkullMeta) item.getItemMeta(); - if (meta != null) { - PlayerProfile profile = Bukkit.createPlayerProfile( - UUID.nameUUIDFromBytes(("FAQ_" + entry.getId()).getBytes()), "FAQ_" + entry.getId()); - PlayerTextures textures = profile.getTextures(); - textures.setSkin(new URL(headTexture)); - profile.setTextures(textures); - meta.setOwnerProfile(profile); - meta.setDisplayName("§e§l" + entry.getQuestion()); - meta.setLore(buildFaqLore(entry, adminHint)); - item.setItemMeta(meta); - } - } catch (Exception e) { - item = buildFallbackItem(entry, adminHint); - } - } else { - item = new ItemStack(headMaterial); - ItemMeta meta = item.getItemMeta(); - if (meta != null) { - meta.setDisplayName("§e§l" + entry.getQuestion()); - meta.setLore(buildFaqLore(entry, adminHint)); - item.setItemMeta(meta); - } - } - return item; - } - - private ItemStack buildFallbackItem(FaqEntry entry, boolean adminHint) { - ItemStack item = new ItemStack(Material.BOOK); - ItemMeta meta = item.getItemMeta(); - if (meta != null) { - meta.setDisplayName("§e§l" + entry.getQuestion()); - meta.setLore(buildFaqLore(entry, adminHint)); - item.setItemMeta(meta); - } + String name = "§e§l" + entry.getQuestion(); + List lore = buildFaqLore(entry, adminHint); + if (headMaterial == Material.PLAYER_HEAD) + return buildSkull("FAQ_" + entry.getId(), headTexture, name, lore); + ItemStack item = new ItemStack(headMaterial); + applyMeta(item, name, lore); return item; } @@ -492,87 +666,105 @@ public class FaqGUI implements Listener { List lore = new ArrayList<>(); lore.add(f("lore-separator")); lore.add(f("lore-id", "{id}", String.valueOf(entry.getId()))); + if (plugin.getFaqManager().hasCategoriesEnabled() && entry.hasCategory()) { + FaqCategory cat = plugin.getFaqManager().getCategoryByKey(entry.getCategoryKey()); + if (cat != null) lore.add(f("lore-category", "{category}", cat.getColored())); + } lore.add(f("lore-separator")); - - String answer = entry.getAnswer(); - int chunkSize = 40; + String answer = entry.getAnswer(); + int chunkSize = 40; for (int i = 0; i < answer.length(); i += chunkSize) { int end = Math.min(i + chunkSize, answer.length()); if (end < answer.length() && answer.charAt(end) != ' ') { - int lastSpace = answer.lastIndexOf(' ', end); - if (lastSpace > i) end = lastSpace; + int ls = answer.lastIndexOf(' ', end); + if (ls > i) end = ls; } lore.add("§f" + answer.substring(i, end).trim()); i = end - chunkSize; } - lore.add(f("lore-separator")); - if (adminHint) lore.add(f("click-edit")); - else lore.add(f("click-detail")); + lore.add(adminHint ? f("click-edit") : f("click-detail")); return lore; } - private ItemStack buildItem(Material material, String displayName, List lore) { - ItemStack item = new ItemStack(material); - ItemMeta meta = item.getItemMeta(); - if (meta == null) return item; - meta.setDisplayName(displayName); + private ItemStack buildSkull(String skinId, String textureUrl, String displayName, List lore) { + try { + ItemStack item = new ItemStack(Material.PLAYER_HEAD); + SkullMeta meta = (SkullMeta) item.getItemMeta(); + if (meta == null) return buildFallback(displayName, lore); + PlayerProfile profile = Bukkit.createPlayerProfile(UUID.nameUUIDFromBytes(skinId.getBytes()), skinId); + PlayerTextures textures = profile.getTextures(); + textures.setSkin(new URL(textureUrl)); + profile.setTextures(textures); + meta.setOwnerProfile(profile); + meta.setDisplayName(displayName); + meta.setLore(lore); + item.setItemMeta(meta); + return item; + } catch (Exception e) { + plugin.getLogger().warning("[FaqGUI] Textur-Fehler für '" + skinId + "': " + e.getMessage()); + return buildFallback(displayName, lore); + } + } + + private ItemStack buildFallback(String name, List lore) { + ItemStack item = new ItemStack(Material.BOOK); + applyMeta(item, name, lore); + return item; + } + + private ItemStack buildItem(Material mat, String name, List lore) { + ItemStack item = new ItemStack(mat); + applyMeta(item, name, lore); + return item; + } + + private void applyMeta(ItemStack item, String name, List lore) { + ItemMeta meta = item.getItemMeta(); + if (meta == null) return; + meta.setDisplayName(name); meta.setLore(lore); item.setItemMeta(meta); - return item; } // ─────────────────────────── Navigationsleiste ───────────────────────── - private void fillNavBar(Inventory inv, int page, int totalPages, boolean isAdmin, boolean isEmpty, int itemCount) { + private void fillNavBar(Inventory inv, int page, int totalPages, boolean isAdmin, + boolean isEmpty, int itemCount, boolean hasCategories) { ItemStack glass = makeGlass(); - // Untere Reihe füllen (Nav-Bar) for (int i = inv.getSize() - 9; i < inv.getSize(); i++) inv.setItem(i, glass); - if (page > 0) { - inv.setItem(faqNavPrev, buildActionItem(matNavPrev, - f("nav-prev"), - List.of(f("nav-prev-lore", "{page}", String.valueOf(page), "{total}", String.valueOf(totalPages))))); - } - if (page < totalPages - 1) { - inv.setItem(faqNavNext, buildActionItem(matNavNext, - f("nav-next"), - List.of(f("nav-next-lore", "{page}", String.valueOf(page + 2), "{total}", String.valueOf(totalPages))))); - } + if (page > 0) + inv.setItem(faqNavPrev, buildItem(matNavPrev, f("nav-prev"), + List.of(f("nav-prev-lore", "{page}", String.valueOf(page), + "{total}", String.valueOf(totalPages))))); + if (page < totalPages - 1) + inv.setItem(faqNavNext, buildItem(matNavNext, f("nav-next"), + List.of(f("nav-next-lore", "{page}", String.valueOf(page + 2), + "{total}", String.valueOf(totalPages))))); - // itemCount ist jetzt die tatsächliche Anzahl der Items auf der Seite - int displayCount = isEmpty ? 0 : itemCount; - - inv.setItem(faqNavPage, buildActionItem(matNavPage, f("nav-page", "{page}", String.valueOf(page + 1), "{total}", String.valueOf(totalPages)), - List.of(f("nav-page-lore", "{count}", String.valueOf(displayCount))))); + inv.setItem(faqNavPage, buildItem(matNavPage, + f("nav-page", "{page}", String.valueOf(page + 1), "{total}", String.valueOf(totalPages)), + List.of(f("nav-page-lore", "{count}", String.valueOf(isEmpty ? 0 : itemCount))))); - if (isAdmin) { - inv.setItem(faqNavAdd, buildActionItem(matNavAdd, f("add-button"), + if (isAdmin) + inv.setItem(faqNavAdd, buildItem(matNavAdd, f("add-button"), List.of(f("add-lore-1"), f("add-lore-2")))); - } + + if (hasCategories) + inv.setItem(getBackButtonSlot(), buildItem(Material.ARROW, + f("cat-back-button"), List.of(f("cat-back-lore")))); } private void fillGlass(Inventory inv) { ItemStack glass = makeGlass(); - for (int i = 0; i < inv.getSize(); i++) { - if (inv.getItem(i) == null) inv.setItem(i, glass); - } + for (int i = 0; i < inv.getSize(); i++) if (inv.getItem(i) == null) inv.setItem(i, glass); } private ItemStack makeGlass() { - ItemStack glass = new ItemStack(Material.GRAY_STAINED_GLASS_PANE); - ItemMeta meta = glass.getItemMeta(); - if (meta != null) { meta.setDisplayName(" "); glass.setItemMeta(meta); } - return glass; - } - - private ItemStack buildActionItem(Material material, String displayName, List lore) { - ItemStack item = new ItemStack(material); - ItemMeta meta = item.getItemMeta(); - if (meta == null) return item; - meta.setDisplayName(displayName); - meta.setLore(lore); - item.setItemMeta(meta); - return item; + ItemStack g = new ItemStack(Material.GRAY_STAINED_GLASS_PANE); + ItemMeta m = g.getItemMeta(); + if (m != null) { m.setDisplayName(" "); g.setItemMeta(m); } + return g; } } \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/manager/FaqManager.java b/src/main/java/de/ticketsystem/manager/FaqManager.java index ff9ea03..1f7c7c0 100644 --- a/src/main/java/de/ticketsystem/manager/FaqManager.java +++ b/src/main/java/de/ticketsystem/manager/FaqManager.java @@ -1,6 +1,7 @@ package de.ticketsystem.manager; import de.ticketsystem.TicketPlugin; +import de.ticketsystem.model.FaqCategory; import de.ticketsystem.model.FaqEntry; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; @@ -10,28 +11,40 @@ import java.io.IOException; import java.util.*; /** - * Manages FAQ entries stored in faqs.yml. + * Manages FAQ entries and FAQ categories stored in faqs.yml. * - * Admins can add, edit and delete FAQs in-game. - * All changes are saved immediately to faqs.yml. + * faqs.yml wird beim ersten Start automatisch mit Beispiel-Kategorien und -FAQs generiert. + * Admins können Kategorien und FAQs direkt in-game verwalten (GUI + Befehle). * - * faqs.yml layout: + * faqs.yml Layout: + * + * categories: + * tickets: + * name: "Tickets" + * color: "&b" + * description: "Fragen zum Ticket-System" * * faqs: * 1: * question: "Wie erstelle ich ein Ticket?" - * answer: "Nutze /ticket create [Kategorie] [Beschreibung]." - * 2: - * question: "..." - * answer: "..." + * answer: "Nutze /ticket create ..." + * category: "tickets" + * + * Material und Textur der Kategorie-Items werden NICHT hier gespeichert – + * sie werden zentral in config.yml unter gui-settings.faq.category-head-item gesteuert. */ public class FaqManager { - private final TicketPlugin plugin; - private final File faqFile; - private YamlConfiguration faqConfig; - private final List entries = new ArrayList<>(); - private int nextId = 1; + public static final String UNCATEGORIZED_KEY = "__none__"; + + private final TicketPlugin plugin; + private final File faqFile; + private YamlConfiguration faqConfig; + + private final List entries = new ArrayList<>(); + private final LinkedHashMap categories = new LinkedHashMap<>(); + + private int nextId = 1; public FaqManager(TicketPlugin plugin) { this.plugin = plugin; @@ -43,6 +56,7 @@ public class FaqManager { private void load() { entries.clear(); + categories.clear(); nextId = 1; if (!faqFile.exists()) { @@ -59,16 +73,37 @@ public class FaqManager { } faqConfig = YamlConfiguration.loadConfiguration(faqFile); - ConfigurationSection section = faqConfig.getConfigurationSection("faqs"); - if (section != null) { - for (String key : section.getKeys(false)) { + // ── 1. Kategorien laden ─────────────────────────────────────────── + ConfigurationSection catSection = faqConfig.getConfigurationSection("categories"); + if (catSection != null && !catSection.getKeys(false).isEmpty()) { + for (String key : catSection.getKeys(false)) { + ConfigurationSection cat = catSection.getConfigurationSection(key); + if (cat == null) continue; + String name = cat.getString("name", capitalize(key)); + String color = cat.getString("color", "&7"); + String desc = cat.getString("description", ""); + categories.put(key.toLowerCase(), new FaqCategory(key, name, color, desc)); + } + if (plugin.isDebug()) { + plugin.getLogger().info("[FaqManager] " + categories.size() + + " FAQ-Kategorie(n) geladen: " + String.join(", ", categories.keySet())); + } + } + + // ── 2. FAQ-Einträge laden ───────────────────────────────────────── + ConfigurationSection faqSection = faqConfig.getConfigurationSection("faqs"); + if (faqSection != null) { + for (String key : faqSection.getKeys(false)) { try { int id = Integer.parseInt(key); String question = faqConfig.getString("faqs." + key + ".question", ""); String answer = faqConfig.getString("faqs." + key + ".answer", ""); + String category = faqConfig.getString("faqs." + key + ".category", null); if (!question.isBlank() && !answer.isBlank()) { - entries.add(new FaqEntry(id, question, answer)); + FaqEntry entry = new FaqEntry(id, question, answer); + entry.setCategoryKey(normalizeCategoryKey(category)); + entries.add(entry); if (id >= nextId) nextId = id + 1; } } catch (NumberFormatException ignored) {} @@ -82,31 +117,56 @@ public class FaqManager { } } - /** Writes the example FAQs into a freshly created faqs.yml. */ private void loadDefaults() { + faqConfig.options().header( + "FAQ-System – faqs.yml\n" + + "Wird automatisch generiert. Kategorien sind optional.\n" + + "Material/Textur der Kategorie-Items: config.yml → gui-settings.faq.category-head-item" + ); + + writeCategory("general", "Allgemein", "&e", "Allgemeine Fragen zum Server"); + writeCategory("rules", "Regeln", "&c", "Fragen zu den Server-Regeln"); + writeCategory("gameplay", "Gameplay", "&a", "Fragen zum Spielgeschehen"); + writeCategory("tickets", "Tickets", "&b", "Fragen zum Ticket-System"); + + categories.put("general", new FaqCategory("general", "Allgemein", "&e", "Allgemeine Fragen zum Server")); + categories.put("rules", new FaqCategory("rules", "Regeln", "&c", "Fragen zu den Server-Regeln")); + categories.put("gameplay", new FaqCategory("gameplay", "Gameplay", "&a", "Fragen zum Spielgeschehen")); + categories.put("tickets", new FaqCategory("tickets", "Tickets", "&b", "Fragen zum Ticket-System")); + writeEntry(1, "Wie erstelle ich ein Ticket?", - "Nutze den Befehl /ticket create [Kategorie] [Prio][Beschreibung] um ein neues Ticket zu erstellen."); + "Nutze den Befehl /ticket create [Kategorie] [Prio] .", "tickets"); writeEntry(2, "Wie lange dauert die Bearbeitung?", - "Unser Support-Team bearbeitet Tickets so schnell wie möglich. Bitte habe etwas Geduld."); + "Unser Support-Team bearbeitet Tickets so schnell wie möglich.", "tickets"); writeEntry(3, "Kann ich mein Ticket löschen?", - "Ja! Öffne /ticket list und klicke auf dein Ticket, um es aus der Übersicht zu entfernen."); + "Ja! Öffne /ticket list und klicke auf dein Ticket.", "tickets"); writeEntry(4, "Wie kann ich meinen Support bewerten?", - "Nach dem Schließen eines Tickets kannst du mit /ticket rate good/bad eine Bewertung abgeben."); + "Mit /ticket rate good/bad nach dem Schließen.", "tickets"); nextId = 5; - // Sync entries list with what we just wrote – Text muss identisch sein! - entries.add(new FaqEntry(1, "Wie erstelle ich ein Ticket?", - "Nutze den Befehl /ticket create [Kategorie] [Prio][Beschreibung] um ein neues Ticket zu erstellen.")); - entries.add(new FaqEntry(2, "Wie lange dauert die Bearbeitung?", - "Unser Support-Team bearbeitet Tickets so schnell wie möglich. Bitte habe etwas Geduld.")); - entries.add(new FaqEntry(3, "Kann ich mein Ticket löschen?", - "Ja! Öffne /ticket list und klicke auf dein Ticket, um es aus der Übersicht zu entfernen.")); - entries.add(new FaqEntry(4, "Wie kann ich meinen Support bewerten?", - "Nach dem Schließen eines Tickets kannst du mit /ticket rate good/bad eine Bewertung abgeben.")); + + for (int i = 1; i <= 4; i++) { + String q = faqConfig.getString("faqs." + i + ".question", ""); + String a = faqConfig.getString("faqs." + i + ".answer", ""); + FaqEntry e = new FaqEntry(i, q, a); + e.setCategoryKey("tickets"); + entries.add(e); + } } - private void writeEntry(int id, String question, String answer) { + // ── YAML-Hilfsmethoden ───────────────────────────────────────────────── + + private void writeCategory(String key, String name, String color, String description) { + faqConfig.set("categories." + key + ".name", name); + faqConfig.set("categories." + key + ".color", color); + faqConfig.set("categories." + key + ".description", description); + } + + private void writeEntry(int id, String question, String answer, String categoryKey) { faqConfig.set("faqs." + id + ".question", question); faqConfig.set("faqs." + id + ".answer", answer); + if (categoryKey != null && !categoryKey.equals(UNCATEGORIZED_KEY)) { + faqConfig.set("faqs." + id + ".category", categoryKey); + } } private void save() { @@ -117,54 +177,130 @@ public class FaqManager { } } - // ─────────────────────────── Public API ──────────────────────────────── + // ─────────────────────────── Public API – Kategorien ─────────────────── - /** Returns an unmodifiable view of all FAQ entries in ID order. */ - public List getAll() { - return Collections.unmodifiableList(entries); + public boolean hasCategoriesEnabled() { return !categories.isEmpty(); } + + public List getAllCategories() { + return Collections.unmodifiableList(new ArrayList<>(categories.values())); } - /** Looks up an entry by its numeric ID. Returns null if not found. */ - public FaqEntry getById(int id) { - return entries.stream().filter(e -> e.getId() == id).findFirst().orElse(null); + public FaqCategory getCategoryByKey(String key) { + if (key == null) return null; + return categories.get(key.toLowerCase()); } /** - * Adds a new FAQ entry and saves immediately. + * Fügt eine neue Kategorie hinzu und speichert sofort. * - * @param question The question text. - * @param answer The answer text. - * @return The newly created {@link FaqEntry}. + * @return null wenn der Schlüssel bereits existiert, sonst die neue FaqCategory. */ - public FaqEntry add(String question, String answer) { - int id = nextId++; - FaqEntry entry = new FaqEntry(id, question, answer); - entries.add(entry); - writeEntry(id, question, answer); + public FaqCategory addCategory(String key, String name, String color, String description) { + String lowerKey = key.toLowerCase().replaceAll("\\s+", "_"); + if (categories.containsKey(lowerKey)) return null; + FaqCategory cat = new FaqCategory(lowerKey, name, color, description); + categories.put(lowerKey, cat); + writeCategory(lowerKey, name, color, description); save(); - return entry; + return cat; } /** - * Edits an existing FAQ entry and saves immediately. + * Bearbeitet eine bestehende Kategorie und speichert sofort. * - * @return true if the entry was found and updated, false otherwise. + * @return true wenn gefunden und aktualisiert. */ - public boolean edit(int id, String question, String answer) { - FaqEntry entry = getById(id); - if (entry == null) return false; - entry.setQuestion(question); - entry.setAnswer(answer); - writeEntry(id, question, answer); + public boolean editCategory(String key, String name, String color, String description) { + String lowerKey = key.toLowerCase(); + if (!categories.containsKey(lowerKey)) return false; + FaqCategory updated = new FaqCategory(lowerKey, name, color, description); + categories.put(lowerKey, updated); + writeCategory(lowerKey, name, color, description); save(); return true; } /** - * Deletes a FAQ entry and saves immediately. + * Löscht eine Kategorie. FAQs dieser Kategorie werden auf UNCATEGORIZED_KEY gesetzt. * - * @return true if the entry was found and deleted, false otherwise. + * @return true wenn gefunden und gelöscht. */ + public boolean deleteCategory(String key) { + String lowerKey = key.toLowerCase(); + if (!categories.containsKey(lowerKey)) return false; + categories.remove(lowerKey); + faqConfig.set("categories." + lowerKey, null); + // FAQs dieser Kategorie auf "keine Kategorie" setzen + for (FaqEntry entry : entries) { + if (lowerKey.equals(entry.getCategoryKey())) { + entry.setCategoryKey(UNCATEGORIZED_KEY); + faqConfig.set("faqs." + entry.getId() + ".category", null); + } + } + save(); + return true; + } + + public List getByCategory(String categoryKey) { + String normalizedKey = normalizeCategoryKey(categoryKey); + List result = new ArrayList<>(); + for (FaqEntry e : entries) { + if (normalizedKey.equals(e.getCategoryKey())) result.add(e); + } + return Collections.unmodifiableList(result); + } + + public int countByCategory(String categoryKey) { + return getByCategory(categoryKey).size(); + } + + // ─────────────────────────── Public API – Einträge ───────────────────── + + public List getAll() { return Collections.unmodifiableList(entries); } + + public FaqEntry getById(int id) { + return entries.stream().filter(e -> e.getId() == id).findFirst().orElse(null); + } + + public FaqEntry add(String question, String answer, String categoryKey) { + int id = nextId++; + String normalizedKey = normalizeCategoryKey(categoryKey); + FaqEntry entry = new FaqEntry(id, question, answer); + entry.setCategoryKey(normalizedKey); + entries.add(entry); + writeEntry(id, question, answer, normalizedKey); + save(); + return entry; + } + + public FaqEntry add(String question, String answer) { + return add(question, answer, null); + } + + public boolean edit(int id, String question, String answer) { + FaqEntry entry = getById(id); + if (entry == null) return false; + entry.setQuestion(question); + entry.setAnswer(answer); + writeEntry(id, question, answer, entry.getCategoryKey()); + save(); + return true; + } + + public boolean setCategory(int id, String categoryKey) { + FaqEntry entry = getById(id); + if (entry == null) return false; + String normalizedKey = normalizeCategoryKey(categoryKey); + entry.setCategoryKey(normalizedKey); + if (normalizedKey.equals(UNCATEGORIZED_KEY)) { + faqConfig.set("faqs." + id + ".category", null); + } else { + faqConfig.set("faqs." + id + ".category", normalizedKey); + } + save(); + return true; + } + public boolean delete(int id) { FaqEntry entry = getById(id); if (entry == null) return false; @@ -174,8 +310,20 @@ public class FaqManager { return true; } - /** Reloads FAQs from faqs.yml without restarting the server. */ - public void reload() { - load(); + public void reload() { load(); } + + // ─────────────────────────── Hilfsmethoden ───────────────────────────── + + private String normalizeCategoryKey(String key) { + if (key == null || key.isBlank()) return UNCATEGORIZED_KEY; + String lower = key.toLowerCase(); + if (lower.equals(UNCATEGORIZED_KEY)) return UNCATEGORIZED_KEY; + if (!categories.isEmpty() && !categories.containsKey(lower)) return UNCATEGORIZED_KEY; + return lower; + } + + private static String capitalize(String s) { + if (s == null || s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase(); } } \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/model/FaqCategory.java b/src/main/java/de/ticketsystem/model/FaqCategory.java new file mode 100644 index 0000000..0a40e35 --- /dev/null +++ b/src/main/java/de/ticketsystem/model/FaqCategory.java @@ -0,0 +1,57 @@ +package de.ticketsystem.model; + +import org.bukkit.Material; + +/** + * Eine FAQ-Kategorie, definiert in faqs.yml unter dem Schlüssel "categories". + * + * Beispiel (faqs.yml): + * + * categories: + * tickets: + * name: "Tickets" + * color: "&b" + * description: "Fragen zum Ticket-System" + * rules: + * name: "Regeln" + * color: "&c" + * description: "Fragen zu den Server-Regeln" + * + * Das Material und die Textur aller Kategorie-Items werden zentral in + * config.yml unter gui-settings.faq.category-head-item gesteuert – + * nicht pro Kategorie einzeln. + */ +public class FaqCategory { + + /** Interner Schlüssel (z.B. "rules", "tickets") – immer Kleinbuchstaben */ + private final String key; + + /** Anzeigename (z.B. "Regeln", "Tickets") */ + private final String name; + + /** Minecraft-Farbcode für den Anzeigenamen (z.B. "&c") */ + private final String color; + + /** Kurzbeschreibung für die Item-Lore (optional, kann leer sein) */ + private final String description; + + public FaqCategory(String key, String name, String color, String description) { + this.key = key.toLowerCase(); + this.name = name; + this.color = color; + this.description = description != null ? description : ""; + } + + public String getKey() { return key; } + public String getName() { return name; } + public String getColor() { return color; } + public String getDescription() { return description; } + + /** Farbiger Anzeigename, z.B. "§cRegeln" */ + public String getColored() { + return org.bukkit.ChatColor.translateAlternateColorCodes('&', color + name); + } + + @Override + public String toString() { return key; } +} \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/model/FaqEntry.java b/src/main/java/de/ticketsystem/model/FaqEntry.java index 05715aa..d1f6b75 100644 --- a/src/main/java/de/ticketsystem/model/FaqEntry.java +++ b/src/main/java/de/ticketsystem/model/FaqEntry.java @@ -2,6 +2,9 @@ package de.ticketsystem.model; /** * Represents a single FAQ entry stored in faqs.yml. + * + * The categoryKey field is optional. If no categories are defined in faqs.yml + * it will always be "__none__" and is ignored by the GUI. */ public class FaqEntry { @@ -9,21 +12,35 @@ public class FaqEntry { private String question; private String answer; + /** + * Optionaler Kategorie-Schlüssel (z.B. "rules", "gameplay"). + * Standardwert: "__none__" + * Wird ignoriert wenn keine FAQ-Kategorien in faqs.yml definiert sind. + */ + private String categoryKey = "__none__"; + public FaqEntry(int id, String question, String answer) { this.id = id; this.question = question; this.answer = answer; } - public int getId() { return id; } - public void setId(int id) { this.id = id; } - public String getQuestion() { return question; } - public void setQuestion(String q) { this.question = q; } - public String getAnswer() { return answer; } - public void setAnswer(String a) { this.answer = a; } + public int getId() { return id; } + public void setId(int id) { this.id = id; } + public String getQuestion() { return question; } + public void setQuestion(String q) { this.question = q; } + public String getAnswer() { return answer; } + public void setAnswer(String a) { this.answer = a; } + public String getCategoryKey() { return categoryKey; } + public void setCategoryKey(String key) { this.categoryKey = key != null ? key : "__none__"; } + + /** Gibt true zurück wenn dieser Eintrag einer Kategorie zugeordnet ist. */ + public boolean hasCategory() { + return categoryKey != null && !categoryKey.equals("__none__"); + } @Override public String toString() { - return "FaqEntry{id=" + id + ", question='" + question + "'}"; + return "FaqEntry{id=" + id + ", question='" + question + "', category='" + categoryKey + "'}"; } } \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/web/SessionManager.java b/src/main/java/de/ticketsystem/web/SessionManager.java new file mode 100644 index 0000000..bd4ce1c --- /dev/null +++ b/src/main/java/de/ticketsystem/web/SessionManager.java @@ -0,0 +1,169 @@ +package de.ticketsystem.web; + +import de.ticketsystem.TicketPlugin; +import org.bukkit.configuration.ConfigurationSection; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Verwaltet Web-Panel-Sessions und Benutzer-Authentifizierung. + * + * Benutzer werden in config.yml definiert: + * + * web-panel: + * users: + * admin: + * password-hash: "sha256hex..." + * role: "admin" + * max: + * password-hash: "sha256hex..." + * role: "supporter" + * + * Passwörter werden als SHA-256-Hex gespeichert. + * Beim ersten Start kann man auch ein Plaintext-Passwort unter "password" angeben – + * es wird beim Laden automatisch gehasht und in der Config ersetzt. + */ +public class SessionManager { + + private final TicketPlugin plugin; + private final Map sessions = new ConcurrentHashMap<>(); + private final SecureRandom random = new SecureRandom(); + + public SessionManager(TicketPlugin plugin) { + this.plugin = plugin; + migratePlaintextPasswords(); + } + + // ─────────────────────────── Login ───────────────────────────────────── + + /** + * Prüft Benutzername + Passwort und erstellt bei Erfolg eine Session. + * + * @return Session-Token oder null bei falschem Login + */ + public String login(String username, String password) { + if (username == null || password == null) return null; + + ConfigurationSection users = plugin.getConfig().getConfigurationSection("web-panel.users"); + if (users == null) return null; + + ConfigurationSection user = users.getConfigurationSection(username.toLowerCase()); + if (user == null) return null; + + String storedHash = user.getString("password-hash", ""); + String inputHash = sha256(password); + + if (!storedHash.equalsIgnoreCase(inputHash)) return null; + + String roleStr = user.getString("role", "supporter").toUpperCase(); + WebSession.Role role; + try { + role = WebSession.Role.valueOf(roleStr); + } catch (IllegalArgumentException e) { + role = WebSession.Role.SUPPORTER; + } + + long timeoutMs = plugin.getConfig().getLong("web-panel.session-timeout-minutes", 60) * 60_000L; + String token = generateToken(); + WebSession session = new WebSession(token, username.toLowerCase(), role, timeoutMs); + sessions.put(token, session); + + if (plugin.isDebug()) { + plugin.getLogger().info("[WebPanel] Login: " + username + " (" + role + ")"); + } + + return token; + } + + /** + * Gibt die Session für ein Token zurück, oder null wenn ungültig/abgelaufen. + * Erneuert den lastAccess-Zeitstempel bei gültiger Session. + */ + public WebSession getSession(String token) { + if (token == null) return null; + WebSession session = sessions.get(token); + if (session == null) return null; + if (session.isExpired()) { + sessions.remove(token); + return null; + } + session.touch(); + return session; + } + + /** + * Meldet eine Session ab. + */ + public void logout(String token) { + sessions.remove(token); + } + + /** + * Entfernt alle abgelaufenen Sessions (periodisch aufrufen). + */ + public void evictExpired() { + Iterator> it = sessions.entrySet().iterator(); + while (it.hasNext()) { + if (it.next().getValue().isExpired()) it.remove(); + } + } + + public int activeSessionCount() { return sessions.size(); } + + // ─────────────────────────── Passwort-Migration ───────────────────────── + + /** + * Wandelt Plaintext-Passwörter ("password") beim ersten Start in Hashes um + * und speichert sie als "password-hash" zurück in die config.yml. + */ + private void migratePlaintextPasswords() { + ConfigurationSection users = plugin.getConfig().getConfigurationSection("web-panel.users"); + if (users == null) return; + + boolean changed = false; + for (String key : users.getKeys(false)) { + ConfigurationSection user = users.getConfigurationSection(key); + if (user == null) continue; + + String plain = user.getString("password", null); + if (plain != null && !plain.isEmpty()) { + String hash = sha256(plain); + plugin.getConfig().set("web-panel.users." + key + ".password-hash", hash); + plugin.getConfig().set("web-panel.users." + key + ".password", null); + plugin.getLogger().info("[WebPanel] Passwort für '" + key + "' wurde gehasht und gespeichert."); + changed = true; + } + } + + if (changed) { + plugin.saveConfig(); + } + } + + // ─────────────────────────── Hilfsmethoden ───────────────────────────── + + private String generateToken() { + byte[] bytes = new byte[24]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public static String sha256(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : hash) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 nicht verfügbar", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/web/WebServer.java b/src/main/java/de/ticketsystem/web/WebServer.java new file mode 100644 index 0000000..e80f3d1 --- /dev/null +++ b/src/main/java/de/ticketsystem/web/WebServer.java @@ -0,0 +1,79 @@ +package de.ticketsystem.web; + +import com.sun.net.httpserver.HttpServer; +import de.ticketsystem.TicketPlugin; +import de.ticketsystem.web.handlers.*; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.concurrent.Executors; + +/** + * Startet und verwaltet den eingebetteten HTTP-Server für das Web-Panel. + * + * Konfiguration in config.yml: + * web-panel: + * enabled: true + * port: 8085 + * bind-address: "0.0.0.0" # optional, Standard: alle Interfaces + */ +public class WebServer { + + private final TicketPlugin plugin; + private final SessionManager sessionManager; + private HttpServer server; + + public WebServer(TicketPlugin plugin, SessionManager sessionManager) { + this.plugin = plugin; + this.sessionManager = sessionManager; + } + + public void start() { + int port = plugin.getConfig().getInt("web-panel.port", 8085); + String bindStr = plugin.getConfig().getString("web-panel.bind-address", "0.0.0.0"); + + try { + InetSocketAddress addr = new InetSocketAddress(bindStr, port); + server = HttpServer.create(addr, 0); + + // Thread-Pool: max 4 Threads für Web-Requests + server.setExecutor(Executors.newFixedThreadPool(4)); + + // ── Routes registrieren ────────────────────────────────────── + StaticHandler staticHandler = new StaticHandler(plugin); + LoginHandler loginHandler = new LoginHandler(plugin, sessionManager); + DashboardHandler dashHandler = new DashboardHandler(plugin, sessionManager); + TicketsHandler ticketsHandler = new TicketsHandler(plugin, sessionManager); + FaqHandler faqHandler = new FaqHandler(plugin, sessionManager); + ApiHandler apiHandler = new ApiHandler(plugin, sessionManager); + + server.createContext("/", loginHandler); + server.createContext("/login", loginHandler); + server.createContext("/logout", loginHandler); + server.createContext("/dashboard", dashHandler); + server.createContext("/tickets", ticketsHandler); + server.createContext("/ticket", ticketsHandler); + server.createContext("/faq", faqHandler); + server.createContext("/api", apiHandler); + server.createContext("/static", staticHandler); + + server.start(); + + plugin.getLogger().info("[WebPanel] HTTP-Server gestartet auf " + bindStr + ":" + port); + + // Session-Cleanup alle 10 Minuten + plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, + () -> sessionManager.evictExpired(), 12000L, 12000L); + + } catch (IOException e) { + plugin.getLogger().severe("[WebPanel] Konnte HTTP-Server nicht starten: " + e.getMessage()); + } + } + + public void stop() { + if (server != null) { + server.stop(1); + plugin.getLogger().info("[WebPanel] HTTP-Server gestoppt."); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/web/WebSession.java b/src/main/java/de/ticketsystem/web/WebSession.java new file mode 100644 index 0000000..ba5b874 --- /dev/null +++ b/src/main/java/de/ticketsystem/web/WebSession.java @@ -0,0 +1,39 @@ +package de.ticketsystem.web; + +/** + * Repräsentiert eine aktive Web-Panel-Session. + */ +public class WebSession { + + public enum Role { + ADMIN, // Voller Zugriff: Tickets, FAQ, Stats, Blacklist, Reload + SUPPORTER // Tickets anzeigen, claimen, schließen, kommentieren + } + + private final String token; + private final String username; + private final Role role; + private long lastAccess; + private final long timeoutMs; + + public WebSession(String token, String username, Role role, long timeoutMs) { + this.token = token; + this.username = username; + this.role = role; + this.timeoutMs = timeoutMs; + this.lastAccess = System.currentTimeMillis(); + } + + public boolean isExpired() { + return System.currentTimeMillis() - lastAccess > timeoutMs; + } + + public void touch() { + this.lastAccess = System.currentTimeMillis(); + } + + public String getToken() { return token; } + public String getUsername() { return username; } + public Role getRole() { return role; } + public boolean isAdmin() { return role == Role.ADMIN; } +} \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/web/handlers/ApiHandler.java b/src/main/java/de/ticketsystem/web/handlers/ApiHandler.java new file mode 100644 index 0000000..d657913 --- /dev/null +++ b/src/main/java/de/ticketsystem/web/handlers/ApiHandler.java @@ -0,0 +1,332 @@ +package de.ticketsystem.web.handlers; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import de.ticketsystem.TicketPlugin; +import de.ticketsystem.database.DatabaseManager; +import de.ticketsystem.model.*; +import de.ticketsystem.web.SessionManager; +import de.ticketsystem.web.WebSession; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * /api/* – JSON-API für AJAX-Aktionen aus dem Web-Panel. + * + * Alle Responses: + * Erfolg: {"ok":true} + * Fehler: {"ok":false,"error":"Beschreibung"} + * + * Endpunkte: + * POST /api/ticket/{id}/claim + * POST /api/ticket/{id}/close body: {comment?} + * POST /api/ticket/{id}/priority body: {priority} + * POST /api/ticket/{id}/comment body: {message} + * POST /api/ticket/{id}/forward body: {target} (admin only) + * GET /api/ticket/{id}/comments + * + * GET /api/faq + * POST /api/faq body: {question, answer, category?} (admin only) + * PUT /api/faq/{id} body: {question, answer, category?} (admin only) + * DELETE /api/faq/{id} (admin only) + */ +public class ApiHandler extends BaseHandler implements HttpHandler { + + private final TicketPlugin plugin; + private final SessionManager sessionManager; + + public ApiHandler(TicketPlugin plugin, SessionManager sessionManager) { + this.plugin = plugin; + this.sessionManager = sessionManager; + } + + @Override + public void handle(HttpExchange ex) throws IOException { + WebSession session = requireSession(ex, sessionManager); + if (session == null) return; + + String path = ex.getRequestURI().getPath(); // z.B. /api/ticket/5/claim + String method = ex.getRequestMethod().toUpperCase(); + + try { + // ── Ticket-Aktionen ────────────────────────────────────────── + if (path.startsWith("/api/ticket/")) { + handleTicketApi(ex, session, path, method); + return; + } + + // ── FAQ-Aktionen ───────────────────────────────────────────── + if (path.startsWith("/api/faq")) { + handleFaqApi(ex, session, path, method); + return; + } + + sendJson(ex, 404, "{\"ok\":false,\"error\":\"Unbekannter Endpunkt\"}"); + + } catch (Exception e) { + plugin.getLogger().warning("[WebPanel/API] Fehler: " + e.getMessage()); + if (plugin.isDebug()) e.printStackTrace(); + sendJson(ex, 500, "{\"ok\":false,\"error\":\"Interner Fehler\"}"); + } + } + + // ─────────────────────────── Ticket-API ──────────────────────────────── + + private void handleTicketApi(HttpExchange ex, WebSession session, String path, String method) throws IOException { + // /api/ticket/{id}/{action} + String[] parts = path.split("/"); // ["", "api", "ticket", "5", "claim"] + if (parts.length < 5) { sendJson(ex, 400, err("Ungültiger Pfad")); return; } + + int ticketId; + try { ticketId = Integer.parseInt(parts[3]); } + catch (NumberFormatException e) { sendJson(ex, 400, err("Ungültige Ticket-ID")); return; } + + String action = parts[4]; // claim | close | priority | comment | forward | comments + DatabaseManager db = plugin.getDatabaseManager(); + Ticket ticket = db.getTicketById(ticketId); + + if (ticket == null) { sendJson(ex, 404, err("Ticket nicht gefunden")); return; } + + switch (action) { + + case "claim" -> { + if (!method.equals("POST")) { sendJson(ex, 405, err("Method not allowed")); return; } + if (ticket.getStatus() != TicketStatus.OPEN) { + sendJson(ex, 400, err("Ticket ist nicht offen")); return; + } + // UUID des Web-Users → wir nutzen einen Pseudo-UUID aus dem Benutzernamen + UUID webUUID = webUserUUID(session.getUsername()); + boolean ok = db.claimTicket(ticketId, webUUID, "[Web] " + session.getUsername()); + if (ok) { + ticket.setClaimerName("[Web] " + session.getUsername()); + plugin.getTicketManager().notifyCreatorClaimed(ticket); + plugin.getTicketCache().invalidate(ticketId); + sendJson(ex, 200, "{\"ok\":true}"); + } else { + sendJson(ex, 500, err("Claim fehlgeschlagen")); + } + } + + case "close" -> { + if (!method.equals("POST")) { sendJson(ex, 405, err("Method not allowed")); return; } + Map body = parseJsonBody(ex); + String comment = body.getOrDefault("comment", ""); + boolean ok = db.closeTicket(ticketId, comment.isEmpty() ? null : comment); + if (ok) { + ticket.setStatus(TicketStatus.CLOSED); + ticket.setCloseComment(comment); + plugin.getTicketManager().notifyCreatorClosed(ticket, "[Web] " + session.getUsername()); + plugin.getTicketCache().invalidate(ticketId); + sendJson(ex, 200, "{\"ok\":true}"); + } else { + sendJson(ex, 500, err("Schließen fehlgeschlagen")); + } + } + + case "priority" -> { + if (!method.equals("POST")) { sendJson(ex, 405, err("Method not allowed")); return; } + Map body = parseJsonBody(ex); + String prioStr = body.getOrDefault("priority", "NORMAL"); + TicketPriority prio = TicketPriority.fromString(prioStr); + boolean ok = db.setTicketPriority(ticketId, prio); + if (ok) { + plugin.getTicketCache().invalidate(ticketId); + sendJson(ex, 200, "{\"ok\":true}"); + } else { + sendJson(ex, 500, err("Priorität setzen fehlgeschlagen")); + } + } + + case "comment" -> { + if (!method.equals("POST")) { sendJson(ex, 405, err("Method not allowed")); return; } + Map body = parseJsonBody(ex); + String msg = body.getOrDefault("message", "").trim(); + if (msg.isEmpty()) { sendJson(ex, 400, err("Nachricht leer")); return; } + + UUID webUUID = webUserUUID(session.getUsername()); + String authorDisplay = "[Web] " + session.getUsername(); + TicketComment comment = new TicketComment(ticketId, webUUID, authorDisplay, msg); + boolean ok = db.addComment(comment); + if (ok) { + // Ersteller benachrichtigen (online oder Pending) + String notifyMsg = plugin.lang().format("comment.notify-online", + "{id}", String.valueOf(ticketId), + "{player}", authorDisplay, + "{message}", msg); + Bukkit.getScheduler().runTask(plugin, () -> { + org.bukkit.entity.Player creator = Bukkit.getPlayer(ticket.getCreatorUUID()); + if (creator != null && creator.isOnline()) { + creator.sendMessage(notifyMsg); + } else { + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> + db.addPendingNotification(ticket.getCreatorUUID(), notifyMsg)); + } + }); + sendJson(ex, 200, "{\"ok\":true}"); + } else { + sendJson(ex, 500, err("Kommentar speichern fehlgeschlagen")); + } + } + + case "forward" -> { + if (!method.equals("POST")) { sendJson(ex, 405, err("Method not allowed")); return; } + if (!session.isAdmin()) { sendJson(ex, 403, err("Kein Zugriff")); return; } + Map body = parseJsonBody(ex); + String targetName = body.getOrDefault("target", "").trim(); + if (targetName.isEmpty()) { sendJson(ex, 400, err("Ziel fehlt")); return; } + + // Spieler-UUID ermitteln: erst Online-Spieler, dann Offline-Cache + OfflinePlayer target = null; + for (var p : Bukkit.getOnlinePlayers()) { + if (p.getName().equalsIgnoreCase(targetName)) { target = p; break; } + } + if (target == null) { + // getOfflinePlayer lädt ggf. aus dem Usercache – nie null, aber + // hasPlayedBefore() == false bedeutet: unbekannter Spieler + OfflinePlayer op = Bukkit.getOfflinePlayer(targetName); + if (op.hasPlayedBefore()) target = op; + } + if (target == null) { sendJson(ex, 404, err("Spieler nicht gefunden: " + targetName)); return; } + + boolean ok = db.forwardTicket(ticketId, target.getUniqueId(), target.getName()); + if (ok) { + ticket.setForwardedToUUID(target.getUniqueId()); + ticket.setForwardedToName(target.getName()); + plugin.getTicketManager().notifyForwardedTo(ticket, "[Web] " + session.getUsername()); + plugin.getTicketManager().notifyCreatorForwarded(ticket); + plugin.getTicketCache().invalidate(ticketId); + sendJson(ex, 200, "{\"ok\":true}"); + } else { + sendJson(ex, 500, err("Weiterleiten fehlgeschlagen")); + } + } + + case "comments" -> { + if (!method.equals("GET")) { sendJson(ex, 405, err("Method not allowed")); return; } + List comments = db.getComments(ticketId); + StringBuilder json = new StringBuilder("["); + for (int i = 0; i < comments.size(); i++) { + TicketComment c = comments.get(i); + if (i > 0) json.append(","); + json.append("{\"author\":\"").append(jsonEsc(c.getAuthorName())) + .append("\",\"message\":\"").append(jsonEsc(c.getMessage())) + .append("\",\"time\":\"").append(c.getCreatedAt() != null ? c.getCreatedAt().getTime() : 0) + .append("\"}"); + } + json.append("]"); + sendJson(ex, 200, json.toString()); + } + + default -> sendJson(ex, 404, err("Unbekannte Aktion: " + action)); + } + } + + // ─────────────────────────── FAQ-API ─────────────────────────────────── + + private void handleFaqApi(HttpExchange ex, WebSession session, String path, String method) throws IOException { + // GET /api/faq – Liste + if (path.equals("/api/faq") && method.equals("GET")) { + List entries = plugin.getFaqManager().getAll(); + StringBuilder json = new StringBuilder("["); + for (int i = 0; i < entries.size(); i++) { + var e = entries.get(i); + if (i > 0) json.append(","); + json.append("{\"id\":").append(e.getId()) + .append(",\"question\":\"").append(jsonEsc(e.getQuestion())) + .append("\",\"answer\":\"").append(jsonEsc(e.getAnswer())) + .append("\",\"category\":\"").append(jsonEsc(e.getCategoryKey())) + .append("\"}"); + } + json.append("]"); + sendJson(ex, 200, json.toString()); + return; + } + + // POST /api/faq – Hinzufügen (admin only) + if (path.equals("/api/faq") && method.equals("POST")) { + if (!session.isAdmin()) { sendJson(ex, 403, err("Kein Zugriff")); return; } + Map body = parseJsonBody(ex); + String q = body.getOrDefault("question", "").trim(); + String a = body.getOrDefault("answer", "").trim(); + String cat = body.getOrDefault("category", ""); + if (q.isEmpty() || a.isEmpty()) { sendJson(ex, 400, err("Frage und Antwort erforderlich")); return; } + plugin.getFaqManager().add(q, a, cat.isEmpty() ? null : cat); + sendJson(ex, 200, "{\"ok\":true}"); + return; + } + + // PUT /api/faq/{id} – Bearbeiten (admin only) + if (path.matches("/api/faq/\\d+") && method.equals("PUT")) { + if (!session.isAdmin()) { sendJson(ex, 403, err("Kein Zugriff")); return; } + int id = Integer.parseInt(path.substring("/api/faq/".length())); + Map body = parseJsonBody(ex); + String q = body.getOrDefault("question", "").trim(); + String a = body.getOrDefault("answer", "").trim(); + String cat = body.getOrDefault("category", ""); + if (q.isEmpty() || a.isEmpty()) { sendJson(ex, 400, err("Frage und Antwort erforderlich")); return; } + boolean ok = plugin.getFaqManager().edit(id, q, a); + if (!cat.isEmpty()) plugin.getFaqManager().setCategory(id, cat); + sendJson(ex, ok ? 200 : 404, ok ? "{\"ok\":true}" : err("FAQ nicht gefunden")); + return; + } + + // DELETE /api/faq/{id} – Löschen (admin only) + if (path.matches("/api/faq/\\d+") && method.equals("DELETE")) { + if (!session.isAdmin()) { sendJson(ex, 403, err("Kein Zugriff")); return; } + int id = Integer.parseInt(path.substring("/api/faq/".length())); + boolean ok = plugin.getFaqManager().delete(id); + sendJson(ex, ok ? 200 : 404, ok ? "{\"ok\":true}" : err("FAQ nicht gefunden")); + return; + } + + sendJson(ex, 404, err("Unbekannter FAQ-Endpunkt")); + } + + // ─────────────────────────── Hilfsmethoden ───────────────────────────── + + /** + * Liest einen JSON-Body und parst ihn als flaches Key-Value-Objekt. + * Kein externer JSON-Parser – minimale Eigenimplementierung. + */ + private Map parseJsonBody(HttpExchange ex) throws IOException { + java.io.InputStream is = ex.getRequestBody(); + String body = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).trim(); + Map result = new java.util.HashMap<>(); + if (body.startsWith("{") && body.endsWith("}")) { + body = body.substring(1, body.length() - 1); + // Einfaches Parsing: "key":"value", "key2":"value2" + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("\"([^\"]+)\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"") + .matcher(body); + while (m.find()) { + result.put(m.group(1), m.group(2) + .replace("\\\"", "\"") + .replace("\\\\", "\\") + .replace("\\n", "\n")); + } + } + return result; + } + + /** + * Erstellt eine deterministische UUID aus einem Web-Benutzernamen. + * Damit kann der Web-User als "Autor" in DB-Einträgen gespeichert werden. + */ + private UUID webUserUUID(String username) { + return UUID.nameUUIDFromBytes(("webpanel:" + username).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + private String err(String msg) { + return "{\"ok\":false,\"error\":\"" + jsonEsc(msg) + "\"}"; + } + + private String jsonEsc(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", ""); + } +} \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/web/handlers/BaseHandler.java b/src/main/java/de/ticketsystem/web/handlers/BaseHandler.java new file mode 100644 index 0000000..804990e --- /dev/null +++ b/src/main/java/de/ticketsystem/web/handlers/BaseHandler.java @@ -0,0 +1,336 @@ +package de.ticketsystem.web.handlers; + +import com.sun.net.httpserver.HttpExchange; +import de.ticketsystem.TicketPlugin; +import de.ticketsystem.web.SessionManager; +import de.ticketsystem.web.WebSession; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +/** + * Gemeinsame Hilfsmethoden für alle HTTP-Handler. + * + * Neue Methoden für i18n und Favicon: + * wl(plugin, key) – Liest einen Web-Panel-Text aus der aktiven Sprachdatei (web.{key}). + * buildFaviconHtml(plugin) – Erzeugt das -Tag (Logo oder Standard-SVG). + * layout(title, content, session, plugin) – Layout mit lokalisierten Nav-Texten + Favicon. + * requireAdmin(ex, session, plugin) – 403-Fehlerseite mit lokalisierten Texten. + * errorPage(title, message, plugin) – Fehlerseite mit lokalisiertem Zurück-Button. + * + * Die parameterlos-Varianten von layout(), requireAdmin() und errorPage() bleiben + * unverändert erhalten (Rückwärtskompatibilität für StaticHandler o. Ä.). + */ +public abstract class BaseHandler { + + // ─────────────────────────── Lang-Hilfsmethode ───────────────────────── + + /** + * Liest einen Web-Panel-Text aus der aktiven Sprachdatei (web.{key}). + * Kein Minecraft-Farbcode-Parsing – reiner Plaintext für HTML. + */ + protected String wl(TicketPlugin plugin, String key) { + return plugin.lang().getRaw("web." + key); + } + + // ─────────────────────────── Favicon ─────────────────────────────────── + + /** + * Erzeugt das {@code } HTML-Tag für den {@code }-Bereich. + * + * Wenn {@code web-panel.logo-file} in der config.yml gesetzt ist, wird das Logo + * als Favicon verwendet (wird bereits als /static/{datei} serviert). + * Ansonsten wird ein eingebettetes SVG-Icon als Data-URI erzeugt. + * + * Unterstützte Formate: png, jpg, jpeg, gif, webp, svg, ico. + */ + protected String buildFaviconHtml(TicketPlugin plugin) { + String logoFile = plugin.getConfig().getString("web-panel.logo-file", "").trim(); + if (!logoFile.isEmpty()) { + String safe = logoFile.replaceAll("[^a-zA-Z0-9._\\-]", "_"); + String ext = safe.contains(".") ? safe.substring(safe.lastIndexOf('.') + 1).toLowerCase() : "png"; + String mime = switch (ext) { + case "svg" -> "image/svg+xml"; + case "jpg", "jpeg" -> "image/jpeg"; + case "gif" -> "image/gif"; + case "webp" -> "image/webp"; + case "ico" -> "image/x-icon"; + default -> "image/png"; + }; + return ""; + } + + // Standard-SVG als Data-URI (kein externes File nötig) + String svg = "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; + String b64 = Base64.getEncoder().encodeToString(svg.getBytes(StandardCharsets.UTF_8)); + return ""; + } + + // ─────────────────────────── Response ────────────────────────────────── + + protected void sendHtml(HttpExchange ex, int status, String html) throws IOException { + byte[] bytes = html.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8"); + ex.sendResponseHeaders(status, bytes.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(bytes); + } + } + + protected void sendJson(HttpExchange ex, int status, String json) throws IOException { + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + ex.sendResponseHeaders(status, bytes.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(bytes); + } + } + + protected void sendRedirect(HttpExchange ex, String location) throws IOException { + ex.getResponseHeaders().set("Location", location); + ex.sendResponseHeaders(302, -1); + ex.getResponseBody().close(); + } + + // ─────────────────────────── Request-Parsing ─────────────────────────── + + protected Map parseQuery(String query) { + Map map = new HashMap<>(); + if (query == null || query.isEmpty()) return map; + for (String pair : query.split("&")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2) { + map.put(decode(kv[0]), decode(kv[1])); + } else if (kv.length == 1) { + map.put(decode(kv[0]), ""); + } + } + return map; + } + + protected Map parseBody(HttpExchange ex) throws IOException { + try (InputStream is = ex.getRequestBody()) { + String body = new String(is.readAllBytes(), StandardCharsets.UTF_8); + return parseQuery(body); + } + } + + protected Map parseCookies(HttpExchange ex) { + Map cookies = new HashMap<>(); + String header = ex.getRequestHeaders().getFirst("Cookie"); + if (header == null) return cookies; + for (String part : header.split(";")) { + String[] kv = part.trim().split("=", 2); + if (kv.length == 2) cookies.put(kv[0].trim(), kv[1].trim()); + } + return cookies; + } + + protected String getCookieToken(HttpExchange ex) { + return parseCookies(ex).get("ts_session"); + } + + protected void setSessionCookie(HttpExchange ex, String token, long timeoutMinutes) { + String cookie = "ts_session=" + token + + "; Path=/" + + "; HttpOnly" + + "; Max-Age=" + (timeoutMinutes * 60); + ex.getResponseHeaders().add("Set-Cookie", cookie); + } + + protected void clearSessionCookie(HttpExchange ex) { + ex.getResponseHeaders().add("Set-Cookie", "ts_session=; Path=/; HttpOnly; Max-Age=0"); + } + + // ─────────────────────────── Auth ────────────────────────────────────── + + /** + * Gibt die aktuelle Session zurück oder null. + * Leitet NICHT automatisch um – das macht der Aufrufer. + */ + protected WebSession getSession(HttpExchange ex, SessionManager sessionManager) { + String token = getCookieToken(ex); + return sessionManager.getSession(token); + } + + /** + * Prüft ob eine gültige Session vorliegt. + * Bei ungültiger Session: Redirect zu /login, gibt null zurück. + */ + protected WebSession requireSession(HttpExchange ex, SessionManager sessionManager) throws IOException { + WebSession session = getSession(ex, sessionManager); + if (session == null) { + sendRedirect(ex, "/login"); + return null; + } + return session; + } + + /** + * Prüft ob Admin-Rolle vorhanden ist (legacy – ohne i18n). + * Bei fehlender Rolle: 403-Fehler mit hart kodierten Texten. + * + * @deprecated Nutze {@link #requireAdmin(HttpExchange, WebSession, TicketPlugin)}. + */ + @Deprecated + protected boolean requireAdmin(HttpExchange ex, WebSession session) throws IOException { + if (!session.isAdmin()) { + sendHtml(ex, 403, errorPage("403 – Kein Zugriff", + "Diese Seite ist nur für Administratoren zugänglich.")); + return false; + } + return true; + } + + /** + * Prüft ob Admin-Rolle vorhanden ist. + * Bei fehlender Rolle: 403-Fehler mit lokalisierten Texten aus der Sprachdatei. + */ + protected boolean requireAdmin(HttpExchange ex, WebSession session, TicketPlugin plugin) throws IOException { + if (!session.isAdmin()) { + sendHtml(ex, 403, errorPage( + wl(plugin, "error-403-title"), + wl(plugin, "error-403-message"), + plugin)); + return false; + } + return true; + } + + // ─────────────────────────── HTML-Hilfsmethoden ──────────────────────── + + /** + * Gibt das gemeinsame Seiten-Layout zurück (legacy – ohne i18n). + * Nav-Texte und Rollenlabels sind hart kodiert (Deutsch). + * + * @deprecated Nutze {@link #layout(String, String, WebSession, TicketPlugin)}. + */ + @Deprecated + protected String layout(String title, String content, WebSession session) { + String username = session != null ? session.getUsername() : ""; + String roleLabel = session != null ? (session.isAdmin() ? "Admin" : "Supporter") : ""; + String roleClass = session != null ? (session.isAdmin() ? "admin" : "supporter") : "supporter"; + String initial = username.isEmpty() ? "?" : String.valueOf(username.charAt(0)).toUpperCase(); + String faqNav = session != null && session.isAdmin() + ? "FAQ" : ""; + + return buildLayoutHtml(title, content, session, username, roleLabel, roleClass, initial, faqNav, + "Dashboard", "Tickets", "Abmelden", ""); + } + + /** + * Gibt das gemeinsame Seiten-Layout zurück. + * Nav-Texte, Rollenlabels und Favicon werden aus Sprachdatei / Logo-Config gelesen. + */ + protected String layout(String title, String content, WebSession session, TicketPlugin plugin) { + String username = session != null ? session.getUsername() : ""; + String roleLabel = session != null + ? (session.isAdmin() ? wl(plugin, "role-admin") : wl(plugin, "role-supporter")) + : ""; + String roleClass = session != null ? (session.isAdmin() ? "admin" : "supporter") : "supporter"; + String initial = username.isEmpty() ? "?" : String.valueOf(username.charAt(0)).toUpperCase(); + String faqNav = session != null && session.isAdmin() + ? "" + escHtml(wl(plugin, "nav-faq")) + "" : ""; + + return buildLayoutHtml(title, content, session, username, roleLabel, roleClass, initial, faqNav, + wl(plugin, "nav-dashboard"), + wl(plugin, "nav-tickets"), + wl(plugin, "nav-logout"), + buildFaviconHtml(plugin)); + } + + /** Interner Builder – beide layout()-Varianten landen hier. */ + private String buildLayoutHtml(String title, String content, WebSession session, + String username, String roleLabel, String roleClass, + String initial, String faqNav, + String navDashboard, String navTickets, String navLogout, + String faviconTag) { + return "" + + "" + + "" + + "" + + "" + + "" + escHtml(title) + " – TicketSystem Panel" + + faviconTag + + "" + + "" + + "" + + "" + + "" + + "" + + "
" + + content + + "
" + + "" + + "" + + ""; + } + + /** + * Einfache Fehlerseite (legacy – Zurück-Button auf Deutsch hart kodiert). + * + * @deprecated Nutze {@link #errorPage(String, String, TicketPlugin)}. + */ + @Deprecated + protected String errorPage(String title, String message) { + return """ + + %s +
+

%s

%s

Zur\u00fcck +
+ """.formatted(title, title, message); + } + + /** + * Einfache Fehlerseite mit lokalisiertem Zurück-Button. + */ + protected String errorPage(String title, String message, TicketPlugin plugin) { + return """ + + %s +
+

%s

%s

%s +
+ """.formatted(title, title, message, wl(plugin, "btn-back")); + } + + protected String escHtml(String s) { + if (s == null) return ""; + return s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + private String decode(String s) { + return URLDecoder.decode(s, StandardCharsets.UTF_8); + } +} \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/web/handlers/DashboardHandler.java b/src/main/java/de/ticketsystem/web/handlers/DashboardHandler.java new file mode 100644 index 0000000..7c8555e --- /dev/null +++ b/src/main/java/de/ticketsystem/web/handlers/DashboardHandler.java @@ -0,0 +1,164 @@ +package de.ticketsystem.web.handlers; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import de.ticketsystem.TicketPlugin; +import de.ticketsystem.database.DatabaseManager; +import de.ticketsystem.model.Ticket; +import de.ticketsystem.model.TicketStatus; +import de.ticketsystem.web.SessionManager; +import de.ticketsystem.web.WebSession; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * /dashboard – Statistiken, offene Tickets Übersicht, Top-Ersteller. + */ +public class DashboardHandler extends BaseHandler implements HttpHandler { + + private final TicketPlugin plugin; + private final SessionManager sessionManager; + + public DashboardHandler(TicketPlugin plugin, SessionManager sessionManager) { + this.plugin = plugin; + this.sessionManager = sessionManager; + } + + @Override + public void handle(HttpExchange ex) throws IOException { + WebSession session = requireSession(ex, sessionManager); + if (session == null) return; + + DatabaseManager db = plugin.getDatabaseManager(); + DatabaseManager.TicketStats stats = db.getTicketStats(); + List allOpen = db.getTicketsByStatus(TicketStatus.OPEN, TicketStatus.CLAIMED, TicketStatus.FORWARDED); + + // Top 5 nach offenen Tickets + allOpen.sort((a, b) -> Long.compare( + b.getCreatedAt() != null ? b.getCreatedAt().getTime() : 0, + a.getCreatedAt() != null ? a.getCreatedAt().getTime() : 0)); + List recent = allOpen.stream().limit(8).toList(); + + String content = buildDashboard(stats, recent, allOpen.size(), session); + sendHtml(ex, 200, layout(wl(plugin, "dash-title"), content, session, plugin)); + } + + private String buildDashboard(DatabaseManager.TicketStats stats, List recent, int openCount, WebSession session) { + // Bewertungs-% berechnen + int ratingTotal = stats.thumbsUp + stats.thumbsDown; + String ratingPct = ratingTotal > 0 + ? String.format("%.0f%%", (stats.thumbsUp * 100.0 / ratingTotal)) + : "—"; + + StringBuilder sb = new StringBuilder(); + + sb.append("

").append(escHtml(wl(plugin, "dash-title"))).append("

"); + + // ── Stats ── + sb.append("
"); + statCard(sb, String.valueOf(stats.total), wl(plugin, "dash-stat-total"), "stat-total"); + statCard(sb, String.valueOf(openCount), wl(plugin, "dash-stat-open"), "stat-open"); + statCard(sb, String.valueOf(stats.closed), wl(plugin, "dash-stat-closed"), "stat-closed"); + statCard(sb, String.valueOf(stats.thumbsUp), wl(plugin, "dash-stat-thumbsup"), "stat-open"); + statCard(sb, String.valueOf(stats.thumbsDown),wl(plugin, "dash-stat-thumbsdown"),"stat-closed"); + statCard(sb, ratingPct, wl(plugin, "dash-stat-rating"), "stat-claimed"); + sb.append("
"); + + // ── Letzte offene Tickets ── + sb.append("
"); + sb.append("
").append(escHtml(wl(plugin, "dash-section-recent"))).append("
"); + sb.append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append(""); + + for (Ticket t : recent) { + String created = t.getCreatedAt() != null + ? new java.text.SimpleDateFormat("dd.MM.yy HH:mm").format(t.getCreatedAt()) : "—"; + String catName = getCategoryName(t); + sb.append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append(""); + } + + if (recent.isEmpty()) { + sb.append(""); + } + + sb.append("
").append(escHtml(wl(plugin, "dash-col-id"))).append("").append(escHtml(wl(plugin, "dash-col-player"))).append("").append(escHtml(wl(plugin, "dash-col-category"))).append("").append(escHtml(wl(plugin, "dash-col-priority"))).append("").append(escHtml(wl(plugin, "dash-col-status"))).append("").append(escHtml(wl(plugin, "dash-col-created"))).append("
#").append(t.getId()).append("").append(escHtml(t.getCreatorName())).append("").append(escHtml(catName)).append("").append(priorityBadge(t)).append("").append(statusBadge(t)).append("").append(created).append("") + .append(escHtml(wl(plugin, "btn-details"))) + .append("
") + .append(escHtml(wl(plugin, "dash-empty"))) + .append("
"); + + // ── Top Ersteller (nur wenn Daten vorhanden) ── + if (!stats.byPlayer.isEmpty()) { + sb.append("
") + .append(escHtml(wl(plugin, "dash-section-top"))) + .append("
"); + sb.append("") + .append("") + .append("") + .append(""); + stats.byPlayer.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(10) + .forEach(e -> sb.append("")); + sb.append("
").append(escHtml(wl(plugin, "dash-col-player"))).append("").append(escHtml(wl(plugin, "dash-col-count"))).append("
").append(escHtml(e.getKey())) + .append("").append(e.getValue()).append("
"); + } + + // ── BungeeCord: Tickets pro Server ── + if (plugin.isBungeeCordEnabled() && !stats.byServer.isEmpty()) { + sb.append("
") + .append(escHtml(wl(plugin, "dash-section-server"))) + .append("
"); + sb.append("") + .append("") + .append("") + .append(""); + stats.byServer.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .forEach(e -> sb.append("")); + sb.append("
").append(escHtml(wl(plugin, "dash-col-server"))).append("").append(escHtml(wl(plugin, "dash-col-count"))).append("
").append(escHtml(e.getKey())) + .append("").append(e.getValue()).append("
"); + } + + return sb.toString(); + } + + private void statCard(StringBuilder sb, String value, String label, String colorClass) { + sb.append("
") + .append(escHtml(value)).append("
").append(escHtml(label)) + .append("
"); + } + + private String statusBadge(Ticket t) { + String s = t.getStatus().name().toLowerCase(); + return "" + escHtml(t.getStatus().getDisplayName()) + ""; + } + + private String priorityBadge(Ticket t) { + String p = t.getPriority().name().toLowerCase(); + return "" + escHtml(t.getPriority().getDisplayName()) + ""; + } + + private String getCategoryName(Ticket t) { + var cat = plugin.getCategoryManager().fromKey(t.getCategoryKey()); + return cat != null ? cat.getName() : t.getCategoryKey(); + } +} \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/web/handlers/FaqHandler.java b/src/main/java/de/ticketsystem/web/handlers/FaqHandler.java new file mode 100644 index 0000000..871d79f --- /dev/null +++ b/src/main/java/de/ticketsystem/web/handlers/FaqHandler.java @@ -0,0 +1,182 @@ +package de.ticketsystem.web.handlers; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import de.ticketsystem.TicketPlugin; +import de.ticketsystem.manager.FaqManager; +import de.ticketsystem.model.FaqCategory; +import de.ticketsystem.model.FaqEntry; +import de.ticketsystem.web.SessionManager; +import de.ticketsystem.web.WebSession; + +import java.io.IOException; +import java.util.List; + +/** + * /faq – FAQ-Verwaltung (Anzeige, Hinzufügen, Bearbeiten, Löschen). + * Nur für Admins zugänglich. + */ +public class FaqHandler extends BaseHandler implements HttpHandler { + + private final TicketPlugin plugin; + private final SessionManager sessionManager; + + public FaqHandler(TicketPlugin plugin, SessionManager sessionManager) { + this.plugin = plugin; + this.sessionManager = sessionManager; + } + + @Override + public void handle(HttpExchange ex) throws IOException { + WebSession session = requireSession(ex, sessionManager); + if (session == null) return; + if (!requireAdmin(ex, session, plugin)) return; + + String content = buildFaqPage(); + sendHtml(ex, 200, layout(wl(plugin, "faq-title"), content, session, plugin)); + } + + private String buildFaqPage() { + FaqManager faq = plugin.getFaqManager(); + List entries = faq.getAll(); + List categories = faq.getAllCategories(); + boolean hasCats = faq.hasCategoriesEnabled(); + + String titleText = wl(plugin, "faq-title"); + String entriesSufx = wl(plugin, "faq-entries-suffix"); + String btnAddText = wl(plugin, "faq-btn-add"); + + StringBuilder sb = new StringBuilder(); + sb.append("
"); + sb.append("

") + .append(escHtml(titleText)).append(" ").append(entries.size()) + .append(" ").append(escHtml(entriesSufx)).append("

"); + sb.append(""); + sb.append("
"); + + // ── Kategorien-Übersicht ── + if (hasCats) { + sb.append("
") + .append(escHtml(wl(plugin, "faq-section-cats"))).append("
"); + sb.append("
"); + for (FaqCategory cat : categories) { + int count = faq.countByCategory(cat.getKey()); + sb.append("") + .append(escHtml(cat.getName())) + .append(" ").append(count).append(""); + } + int uncatCount = faq.countByCategory(FaqManager.UNCATEGORIZED_KEY); + if (uncatCount > 0) { + sb.append("") + .append(escHtml(wl(plugin, "faq-no-category"))) + .append(" ").append(uncatCount).append(""); + } + sb.append("
"); + } + + // ── FAQ-Einträge ── + sb.append("
"); + for (FaqEntry e : entries) { + String catLabel = ""; + if (hasCats && e.hasCategory()) { + FaqCategory cat = faq.getCategoryByKey(e.getCategoryKey()); + catLabel = cat != null + ? " " + + escHtml(cat.getName()) + "" + : ""; + } + + sb.append("
"); + sb.append("#").append(e.getId()).append(""); + sb.append("
"); + sb.append("
").append(escHtml(e.getQuestion())).append(catLabel).append("
"); + sb.append("
").append(escHtml(e.getAnswer())).append("
"); + sb.append("
"); + sb.append("
"); + sb.append(""); + sb.append(""); + sb.append("
"); + } + + if (entries.isEmpty()) { + sb.append("
") + .append(escHtml(wl(plugin, "faq-empty"))).append("
"); + } + sb.append("
"); + + // ── Modal: FAQ hinzufügen ── + sb.append("
"); + sb.append("
"); + sb.append("
").append(escHtml(wl(plugin, "faq-modal-add-title"))).append("
"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + + if (hasCats) { + sb.append("
"); + sb.append("
"); + } else { + sb.append(""); + } + + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append("
"); + + // ── Modal: FAQ bearbeiten ── + sb.append("
"); + sb.append("
"); + sb.append("
").append(escHtml(wl(plugin, "faq-modal-edit-title"))).append("
"); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append("
"); + sb.append("
"); + + if (hasCats) { + sb.append("
"); + sb.append("
"); + } else { + sb.append(""); + } + + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("
"); + sb.append("
"); + sb.append("
"); + + return sb.toString(); + } + + /** Escaped einen String für die Verwendung in JS-Template-Literals (Backticks). */ + private String escJs(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("`", "\\`").replace("$", "\\$"); + } +} \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/web/handlers/LoginHandler.java b/src/main/java/de/ticketsystem/web/handlers/LoginHandler.java new file mode 100644 index 0000000..c0d8667 --- /dev/null +++ b/src/main/java/de/ticketsystem/web/handlers/LoginHandler.java @@ -0,0 +1,304 @@ +package de.ticketsystem.web.handlers; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import de.ticketsystem.TicketPlugin; +import de.ticketsystem.web.SessionManager; +import de.ticketsystem.web.WebSession; + +import java.io.IOException; +import java.util.Map; + +/** + * Verarbeitet /login (GET + POST) und /logout. + * + * Logo-Konfiguration in config.yml: + * web-panel: + * logo-file: "logo.png" # Datei im Plugin-Datenordner (optional) + * # Unterstützte Formate: png, jpg, gif, webp, svg + * # Leer lassen = Standard-Icon wird verwendet + * + * Das Logo wird auch als Favicon verwendet (siehe BaseHandler.buildFaviconHtml). + */ +public class LoginHandler extends BaseHandler implements HttpHandler { + + private final TicketPlugin plugin; + private final SessionManager sessionManager; + + public LoginHandler(TicketPlugin plugin, SessionManager sessionManager) { + this.plugin = plugin; + this.sessionManager = sessionManager; + } + + @Override + public void handle(HttpExchange ex) throws IOException { + String path = ex.getRequestURI().getPath(); + String method = ex.getRequestMethod(); + + // ── /logout ────────────────────────────────────────────────────── + if (path.equals("/logout")) { + String token = getCookieToken(ex); + if (token != null) sessionManager.logout(token); + clearSessionCookie(ex); + sendRedirect(ex, "/login"); + return; + } + + // ── Bereits eingeloggt? ────────────────────────────────────────── + WebSession existing = getSession(ex, sessionManager); + if (existing != null) { + sendRedirect(ex, "/dashboard"); + return; + } + + // ── GET /login ─────────────────────────────────────────────────── + if (method.equalsIgnoreCase("GET")) { + sendHtml(ex, 200, loginPage("")); + return; + } + + // ── POST /login ────────────────────────────────────────────────── + if (method.equalsIgnoreCase("POST")) { + Map body = parseBody(ex); + String username = body.getOrDefault("username", "").trim(); + String password = body.getOrDefault("password", ""); + + String token = sessionManager.login(username, password); + if (token == null) { + sendHtml(ex, 401, loginPage(wl(plugin, "login-error"))); + return; + } + + long timeoutMin = plugin.getConfig().getLong("web-panel.session-timeout-minutes", 60); + setSessionCookie(ex, token, timeoutMin); + sendRedirect(ex, "/dashboard"); + return; + } + + ex.sendResponseHeaders(405, -1); + ex.getResponseBody().close(); + } + + // ─────────────────────────── Logo-Hilfsmethoden ──────────────────────── + + /** + * Gibt das Logo-HTML zurück. + * Wenn "web-panel.logo-file" in der config.yml gesetzt ist, wird ein -Tag + * mit /static/logo (+ Dateiendung) verwendet. Ansonsten das Standard-SVG. + */ + private String buildLogoHtml(String sizeClass) { + String logoFile = plugin.getConfig().getString("web-panel.logo-file", "").trim(); + if (!logoFile.isEmpty()) { + String safeName = logoFile.replaceAll("[^a-zA-Z0-9._\\-]", "_"); + return "Logo"; + } + // Standard-SVG-Icon (Ticket-Symbol) + return "" + + "" + + "" + + "" + + "" + + "" + + ""; + } + + // ─────────────────────────── Login-Seite ──────────────────────────────── + + private String loginPage(String error) { + String errorHtml = error.isEmpty() ? "" : + ""; + + String logoLeft = buildLogoHtml("logo-icon-lg"); + String logoRight = buildLogoHtml("logo-icon-sm"); + + return "" + + "" + + "" + + "" + + "" + + "" + escHtml(wl(plugin, "login-title")) + "" + + buildFaviconHtml(plugin) + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; + } + + private String getBrandName() { + return plugin.getConfig().getString("web-panel.brand-name", "TicketSystem"); + } + + private String getTagline() { + return plugin.getConfig().getString("web-panel.tagline", "Verwalte Support-Tickets effizient und übersichtlich."); + } + + // ─────────────────────────── Login-spezifisches CSS ──────────────────── + + private static String loginCss() { + return "" + // Basis + + "html,body{height:100%;}" + + ".login-body{background:var(--bg);font-family:'Inter',system-ui,sans-serif;}" + + // Zweispaltiges Layout + + ".login-split{" + + "display:flex;height:100vh;min-height:600px;" + + "}" + + // Linke Panel + + ".login-left{" + + "flex:0 0 42%;display:flex;align-items:center;justify-content:center;" + + "background:linear-gradient(160deg,#0d1526 0%,#0a1020 55%,#081020 100%);" + + "border-right:1px solid rgba(255,255,255,.06);" + + "position:relative;overflow:hidden;" + + "}" + // Dezente Glüheffekte links + + ".login-left::before{" + + "content:'';position:absolute;width:400px;height:400px;border-radius:50%;" + + "background:radial-gradient(circle,rgba(59,130,246,.12) 0%,transparent 70%);" + + "top:-80px;left:-80px;pointer-events:none;" + + "}" + + ".login-left::after{" + + "content:'';position:absolute;width:300px;height:300px;border-radius:50%;" + + "background:radial-gradient(circle,rgba(99,102,241,.08) 0%,transparent 70%);" + + "bottom:-60px;right:-60px;pointer-events:none;" + + "}" + + ".login-left-inner{" + + "display:flex;flex-direction:column;align-items:center;gap:1.25rem;" + + "text-align:center;padding:2rem;position:relative;z-index:1;" + + "animation:fadeUp .5s ease both;" + + "}" + + ".login-brand{" + + "font-size:1.75rem;font-weight:800;color:#e8ecf5;letter-spacing:-.03em;margin-top:.25rem;" + + "}" + + ".login-tagline{" + + "font-size:.875rem;color:rgba(180,190,220,.55);line-height:1.6;max-width:260px;" + + "}" + + // Rechte Panel + + ".login-right{" + + "flex:1;display:flex;align-items:center;justify-content:center;" + + "background:#0b0f1a;" + + "}" + + ".login-form-wrap{" + + "width:100%;max-width:420px;padding:2.5rem 2rem;" + + "display:flex;flex-direction:column;align-items:center;gap:.75rem;" + + "animation:fadeUp .45s .1s ease both;" + + "}" + // Damit Formular-Felder volle Breite behalten + + ".login-form-wrap form,.login-form-wrap .login-alert{width:100%;}" + + ".login-heading{" + + "font-size:1.6rem;font-weight:800;color:#e8ecf5;letter-spacing:-.03em;" + + "margin-top:.25rem;text-align:center;width:100%;" + + "}" + + ".login-sub{" + + "font-size:.875rem;color:rgba(160,170,210,.55);margin-bottom:.5rem;" + + "text-align:center;width:100%;" + + "}" + + // Alert + + ".login-alert{" + + "padding:.75rem 1rem;border-radius:8px;font-size:.82rem;font-weight:500;" + + "background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.25);" + + "color:#fca5a5;margin-bottom:.25rem;" + + "}" + + // Formular-Felder + + ".login-form{display:flex;flex-direction:column;gap:1rem;margin-top:.25rem;}" + + ".lf-group{display:flex;flex-direction:column;gap:.4rem;}" + + ".lf-label{font-size:.78rem;font-weight:600;color:rgba(160,170,210,.7);letter-spacing:.03em;}" + + ".lf-input{" + + "padding:.7rem 1rem;border-radius:8px;font-size:.9rem;font-family:inherit;" + + "background:#111827;border:1px solid rgba(255,255,255,.1);color:#e2e4ef;" + + "outline:none;transition:border-color .15s,box-shadow .15s;" + + "}" + + ".lf-input:focus{" + + "border-color:rgba(59,130,246,.55);" + + "box-shadow:0 0 0 3px rgba(59,130,246,.12);" + + "}" + + ".lf-input::placeholder{color:rgba(130,140,180,.35);}" + + // Login-Button + + ".login-btn{" + + "margin-top:.5rem;padding:.8rem 1rem;border-radius:8px;font-size:.9rem;" + + "font-weight:600;font-family:inherit;cursor:pointer;border:none;" + + "background:#1e3a5f;color:#e2eaf8;" + + "transition:background .15s,transform .1s,box-shadow .15s;" + + "letter-spacing:.01em;" + + "}" + + ".login-btn:hover{" + + "background:#25487a;" + + "box-shadow:0 0 20px rgba(59,130,246,.2);" + + "}" + + ".login-btn:active{transform:scale(.99);}" + + // Footer-Text + + ".login-footer{" + + "margin-top:1.5rem;text-align:center;font-size:.75rem;" + + "color:rgba(120,130,170,.4);width:100%;" + + "}" + + // Logo-Icons + + ".logo-icon-lg{width:100px;height:100px;}" + + ".logo-icon-sm{width:72px;height:72px;display:block;margin:0 auto .75rem;}" + + // Animation + + "@keyframes fadeUp{from{opacity:0;transform:translateY(16px);}to{opacity:1;transform:translateY(0);}}" + + // Responsiv: unterhalb 768px Spalten stapeln + + "@media(max-width:768px){" + + ".login-split{flex-direction:column;height:auto;min-height:100vh;}" + + ".login-left{flex:none;padding:2.5rem 1rem;border-right:none;" + + "border-bottom:1px solid rgba(255,255,255,.06);}" + + ".login-left-inner{flex-direction:row;text-align:left;gap:1rem;}" + + ".login-brand{font-size:1.25rem;}" + + ".login-tagline{display:none;}" + + ".logo-icon-lg{width:64px;height:64px;}" + + ".logo-icon-sm{width:56px;height:56px;}" + + ".login-right{flex:1;padding:1rem;}" + + ".login-form-wrap{padding:1.5rem 1rem;}" + + "}"; + } +} \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/web/handlers/StaticHandler.java b/src/main/java/de/ticketsystem/web/handlers/StaticHandler.java new file mode 100644 index 0000000..4075e35 --- /dev/null +++ b/src/main/java/de/ticketsystem/web/handlers/StaticHandler.java @@ -0,0 +1,436 @@ +package de.ticketsystem.web.handlers; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import de.ticketsystem.TicketPlugin; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Map; + +/** + * Liefert statische Ressourcen: style.css, panel.js und optional ein Logo/Favicon. + * + * Logo konfigurieren: + * web-panel: + * logo-file: "logo.png" # Datei im Plugin-Datenordner ablegen + * + * Unterstützte Bildformate: png, jpg/jpeg, gif, webp, svg, ico + */ +public class StaticHandler implements HttpHandler { + + private static final Map IMAGE_MIME = Map.of( + "png", "image/png", + "jpg", "image/jpeg", + "jpeg", "image/jpeg", + "gif", "image/gif", + "webp", "image/webp", + "svg", "image/svg+xml", + "ico", "image/x-icon" // <-- NEU: Support für .ico Dateien + ); + + private final TicketPlugin plugin; + + public StaticHandler(TicketPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void handle(HttpExchange ex) throws IOException { + String path = ex.getRequestURI().getPath(); + + if (path.endsWith("style.css")) { + sendText(ex, "text/css", buildCss()); + return; + } + if (path.endsWith("panel.js")) { + sendText(ex, "application/javascript", buildJs()); + return; + } + + // ── Logo & Favicon servieren ─────────────────────────────────── + String logoFile = plugin.getConfig().getString("web-panel.logo-file", "").trim(); + if (!logoFile.isEmpty()) { + // Sicherheitscheck: keine Pfad-Traversal-Angriffe + String safeName = new File(logoFile).getName(); + String requestedName = new File(path).getName(); + + // NEU: Prüfen ob der Browser nach /favicon.ico fragt, oder das Logo direkt angefragt wird + boolean isFaviconRequest = path.endsWith("/favicon.ico"); + + if (safeName.equals(requestedName) || isFaviconRequest) { + // Wir nutzen die Endung der konfigurierten Logo-Datei für den MIME-Type + // (falls logo.png als favicon.ico ausgeliefert wird, bleibt der MIME-Type image/png, was Browser problemlos schlucken) + String ext = getExtension(safeName).toLowerCase(); + String mime = IMAGE_MIME.get(ext); + + if (mime != null) { + File logoOnDisk = new File(plugin.getDataFolder(), safeName); + if (logoOnDisk.exists() && logoOnDisk.isFile()) { + byte[] bytes = Files.readAllBytes(logoOnDisk.toPath()); + ex.getResponseHeaders().set("Content-Type", mime); + ex.getResponseHeaders().set("Cache-Control", "max-age=300"); + ex.sendResponseHeaders(200, bytes.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(bytes); + } + return; + } + } + } + } + + ex.sendResponseHeaders(404, -1); + ex.getResponseBody().close(); + } + + private String getExtension(String filename) { + int dot = filename.lastIndexOf('.'); + return dot >= 0 ? filename.substring(dot + 1) : ""; + } + + private void sendText(HttpExchange ex, String ct, String content) throws IOException { + byte[] b = content.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", ct + "; charset=UTF-8"); + ex.getResponseHeaders().set("Cache-Control", "max-age=60"); + ex.sendResponseHeaders(200, b.length); + try (OutputStream os = ex.getResponseBody()) { os.write(b); } + } + + + private static String buildCss() { + StringBuilder s = new StringBuilder(); + + // Reset & Variablen + s.append("*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}"); + s.append(":root{"); + s.append("--bg:#07080f;"); + s.append("--surface:#0e1018;"); + s.append("--surface2:#13161f;"); + s.append("--surface3:#1a1e2c;"); + s.append("--border:#1f2335;"); + s.append("--border2:#2a2f45;"); + s.append("--accent:#6366f1;"); + s.append("--accent-hover:#4f52d8;"); + s.append("--accent-glow:rgba(99,102,241,.18);"); + s.append("--text:#e2e4ef;"); + s.append("--text2:#9499b8;"); + s.append("--text3:#5c6080;"); + s.append("--green:#22c55e;"); + s.append("--yellow:#eab308;"); + s.append("--orange:#f97316;"); + s.append("--red:#ef4444;"); + s.append("--blue:#3b82f6;"); + s.append("--radius:10px;"); + s.append("--radius-sm:6px;"); + s.append("--shadow:0 4px 24px rgba(0,0,0,.4);"); + s.append("}"); + + // Body + s.append("body{font-family:'Inter',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;line-height:1.5;font-size:14px;}"); + + // Navbar + s.append(".navbar{display:flex;align-items:center;padding:0 2rem;height:56px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100;gap:2rem;backdrop-filter:blur(12px);}"); + s.append(".nav-brand{font-size:.95rem;font-weight:700;color:var(--accent);letter-spacing:-.01em;white-space:nowrap;}"); + s.append(".nav-links{display:flex;gap:2px;flex:1;}"); + s.append(".nav-link{padding:.45rem .85rem;border-radius:var(--radius-sm);color:var(--text2);text-decoration:none;font-size:.85rem;font-weight:500;transition:all .15s;white-space:nowrap;}"); + s.append(".nav-link:hover{background:var(--surface3);color:var(--text);}"); + s.append(".nav-link.active{background:var(--accent-glow);color:var(--accent);}"); + s.append(".nav-user{display:flex;align-items:center;gap:.75rem;margin-left:auto;}"); + s.append(".nav-avatar{width:30px;height:30px;border-radius:50%;background:var(--accent-glow);border:1px solid var(--accent);display:flex;align-items:center;justify-content:center;font-size:.75rem;font-weight:700;color:var(--accent);}"); + s.append(".nav-username{font-size:.85rem;color:var(--text2);font-weight:500;}"); + + // Container + s.append(".container{max-width:1280px;margin:0 auto;padding:2rem 1.5rem;}"); + + // Page title + s.append(".page-title{font-size:1.3rem;font-weight:700;letter-spacing:-.02em;margin-bottom:1.5rem;color:var(--text);}"); + s.append(".page-title span{color:var(--text3);font-weight:400;font-size:.9rem;margin-left:.5rem;}"); + + // Cards + s.append(".card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.5rem;margin-bottom:1.25rem;}"); + s.append(".card-title{font-size:.7rem;font-weight:600;color:var(--text3);margin-bottom:1.25rem;text-transform:uppercase;letter-spacing:.08em;}"); + + // Stats + s.append(".stats-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:.875rem;margin-bottom:2rem;}"); + s.append(".stat-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.25rem;position:relative;overflow:hidden;transition:border-color .2s;}"); + s.append(".stat-card:hover{border-color:var(--border2);}"); + s.append(".stat-card::before{content:'';position:absolute;inset:0;background:linear-gradient(135deg,currentColor 0%,transparent 60%);opacity:.04;pointer-events:none;}"); + s.append(".stat-value{font-size:1.75rem;font-weight:800;line-height:1;margin-bottom:.3rem;letter-spacing:-.03em;}"); + s.append(".stat-label{font-size:.72rem;color:var(--text3);text-transform:uppercase;letter-spacing:.06em;font-weight:500;}"); + s.append(".stat-open{color:var(--green);}"); + s.append(".stat-closed{color:var(--red);}"); + s.append(".stat-total{color:var(--accent);}"); + s.append(".stat-claimed{color:var(--yellow);}"); + + // Table + s.append(".table-wrap{overflow-x:auto;margin:-1.5rem;padding:0;}"); + s.append("table{width:100%;border-collapse:collapse;}"); + s.append("th{text-align:left;padding:.65rem 1.25rem;font-size:.7rem;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.07em;border-bottom:1px solid var(--border);white-space:nowrap;}"); + s.append("td{padding:.8rem 1.25rem;border-bottom:1px solid var(--border);font-size:.875rem;vertical-align:middle;}"); + s.append("tr:last-child td{border-bottom:none;}"); + s.append("tbody tr{transition:background .1s;}"); + s.append("tbody tr:hover td{background:var(--surface2);}"); + + // Badges + s.append(".badge{display:inline-flex;align-items:center;gap:.3rem;padding:.2rem .6rem;border-radius:20px;font-size:.72rem;font-weight:600;line-height:1.5;white-space:nowrap;}"); + s.append(".badge-open{background:rgba(34,197,94,.12);color:var(--green);border:1px solid rgba(34,197,94,.2);}"); + s.append(".badge-claimed{background:rgba(234,179,8,.1);color:var(--yellow);border:1px solid rgba(234,179,8,.2);}"); + s.append(".badge-forwarded{background:rgba(249,115,22,.1);color:var(--orange);border:1px solid rgba(249,115,22,.2);}"); + s.append(".badge-closed{background:rgba(239,68,68,.1);color:var(--red);border:1px solid rgba(239,68,68,.2);}"); + s.append(".badge-admin{background:var(--accent-glow);color:var(--accent);border:1px solid rgba(99,102,241,.25);}"); + s.append(".badge-supporter{background:rgba(34,197,94,.1);color:var(--green);border:1px solid rgba(34,197,94,.2);}"); + s.append(".badge-low{background:rgba(34,197,94,.1);color:var(--green);border:1px solid rgba(34,197,94,.2);}"); + s.append(".badge-normal{background:rgba(234,179,8,.1);color:var(--yellow);border:1px solid rgba(234,179,8,.2);}"); + s.append(".badge-high{background:rgba(249,115,22,.1);color:var(--orange);border:1px solid rgba(249,115,22,.2);}"); + s.append(".badge-urgent{background:rgba(239,68,68,.1);color:var(--red);border:1px solid rgba(239,68,68,.2);}"); + + // Buttons + s.append(".btn{display:inline-flex;align-items:center;justify-content:center;gap:.4rem;padding:.5rem 1rem;border-radius:var(--radius-sm);font-size:.825rem;font-weight:500;cursor:pointer;text-decoration:none;border:1px solid transparent;transition:all .15s;line-height:1;white-space:nowrap;font-family:inherit;}"); + s.append(".btn:disabled{opacity:.5;cursor:not-allowed;}"); + s.append(".btn-primary{background:var(--accent);color:#fff;border-color:var(--accent);}"); + s.append(".btn-primary:hover{background:var(--accent-hover);border-color:var(--accent-hover);}"); + s.append(".btn-success{background:rgba(34,197,94,.15);color:var(--green);border-color:rgba(34,197,94,.25);}"); + s.append(".btn-success:hover{background:rgba(34,197,94,.25);}"); + s.append(".btn-warning{background:rgba(234,179,8,.15);color:var(--yellow);border-color:rgba(234,179,8,.25);}"); + s.append(".btn-warning:hover{background:rgba(234,179,8,.25);}"); + s.append(".btn-danger{background:rgba(239,68,68,.15);color:var(--red);border-color:rgba(239,68,68,.25);}"); + s.append(".btn-danger:hover{background:rgba(239,68,68,.25);}"); + s.append(".btn-secondary{background:var(--surface2);color:var(--text2);border-color:var(--border);}"); + s.append(".btn-secondary:hover{background:var(--surface3);color:var(--text);border-color:var(--border2);}"); + s.append(".btn-sm{padding:.3rem .65rem;font-size:.775rem;}"); + s.append(".btn-xs{padding:.2rem .5rem;font-size:.72rem;}"); + + // Forms + s.append(".form-group{margin-bottom:1rem;}"); + s.append("label{display:block;margin-bottom:.4rem;font-size:.8rem;font-weight:500;color:var(--text2);}"); + s.append("input,textarea,select{width:100%;padding:.55rem .85rem;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:.875rem;outline:none;transition:border-color .15s,box-shadow .15s;font-family:inherit;appearance:none;}"); + s.append("input:focus,textarea:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow);}"); + s.append("input::placeholder,textarea::placeholder{color:var(--text3);}"); + s.append("textarea{resize:vertical;min-height:80px;line-height:1.6;}"); + s.append("select{background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%235c6080' d='M6 8L1 3h10z'/%3E%3C/svg%3E\");background-repeat:no-repeat;background-position:right .75rem center;padding-right:2rem;}"); + + // Login + s.append(".login-wrap{display:flex;align-items:center;justify-content:center;min-height:100vh;background:radial-gradient(ellipse at 50% 0%,rgba(99,102,241,.08) 0%,transparent 60%);}"); + s.append(".login-box{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:2.5rem;width:100%;max-width:380px;box-shadow:var(--shadow);}"); + s.append(".login-logo{text-align:center;font-size:2.5rem;margin-bottom:.75rem;}"); + s.append(".login-title{text-align:center;font-size:1.15rem;font-weight:700;margin-bottom:.25rem;letter-spacing:-.02em;}"); + s.append(".login-sub{text-align:center;font-size:.8rem;color:var(--text3);margin-bottom:2rem;}"); + + // Alert + s.append(".alert{padding:.75rem 1rem;border-radius:var(--radius-sm);margin-bottom:1rem;font-size:.85rem;display:flex;align-items:center;gap:.5rem;}"); + s.append(".alert-danger{background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.2);color:#fca5a5;}"); + s.append(".alert-success{background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.2);color:#86efac;}"); + s.append(".alert-info{background:var(--accent-glow);border:1px solid rgba(99,102,241,.2);color:#a5b4fc;}"); + + // Ticket Detail + s.append(".ticket-header{margin-bottom:1.5rem;}"); + s.append(".ticket-id{font-size:1.4rem;font-weight:800;letter-spacing:-.03em;color:var(--text);margin-bottom:.75rem;}"); + s.append(".ticket-id span{color:var(--accent);}"); + s.append(".ticket-meta{display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;}"); + s.append(".ticket-message{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:1.25rem;margin-bottom:1.5rem;line-height:1.7;white-space:pre-wrap;font-size:.875rem;color:var(--text2);}"); + s.append(".info-grid{display:grid;grid-template-columns:1fr 1fr;gap:.6rem 2rem;margin-bottom:1.5rem;}"); + s.append(".info-row{display:flex;gap:.75rem;font-size:.85rem;}"); + s.append(".info-label{color:var(--text3);min-width:120px;font-size:.8rem;padding-top:.05rem;}"); + s.append(".info-val{color:var(--text2);}"); + + // Actions bar + s.append(".actions-bar{display:flex;flex-wrap:wrap;gap:.625rem;align-items:center;padding:1.25rem;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:1.25rem;}"); + s.append(".actions-bar .sep{width:1px;height:20px;background:var(--border2);margin:0 .125rem;}"); + + // Comments + s.append(".comments{display:flex;flex-direction:column;gap:.75rem;margin-bottom:1.25rem;}"); + s.append(".comment{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;}"); + s.append(".comment-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;}"); + s.append(".comment-author{font-weight:600;font-size:.825rem;color:var(--accent);}"); + s.append(".comment-time{font-size:.75rem;color:var(--text3);}"); + s.append(".comment-text{line-height:1.6;font-size:.875rem;color:var(--text2);white-space:pre-wrap;}"); + s.append(".comment-input-wrap{display:flex;gap:.75rem;align-items:flex-end;}"); + s.append(".comment-input-wrap textarea{flex:1;min-height:70px;}"); + + // Filter bar + s.append(".filter-bar{display:flex;gap:.625rem;flex-wrap:wrap;margin-bottom:1.25rem;align-items:center;}"); + s.append(".filter-bar select{width:auto;font-size:.825rem;padding:.4rem .75rem;padding-right:2rem;}"); + s.append(".search-wrap{display:flex;gap:.5rem;align-items:center;margin-left:auto;}"); + s.append(".search-wrap input{width:200px;font-size:.825rem;padding:.4rem .75rem;}"); + + // Pagination + s.append(".pagination{display:flex;gap:.375rem;margin-top:1.25rem;justify-content:center;flex-wrap:wrap;}"); + s.append(".page-btn{padding:.35rem .75rem;border-radius:var(--radius-sm);background:var(--surface2);border:1px solid var(--border);color:var(--text2);text-decoration:none;font-size:.8rem;cursor:pointer;transition:all .15s;}"); + s.append(".page-btn:hover,.page-btn.active{background:var(--accent);border-color:var(--accent);color:#fff;}"); + + // FAQ + s.append(".faq-list{display:flex;flex-direction:column;}"); + s.append(".faq-entry{display:flex;gap:1rem;align-items:flex-start;padding:1rem 0;border-bottom:1px solid var(--border);}"); + s.append(".faq-entry:last-child{border-bottom:none;}"); + s.append(".faq-num{color:var(--text3);font-size:.8rem;font-weight:600;min-width:28px;padding-top:.1rem;}"); + s.append(".faq-body{flex:1;}"); + s.append(".faq-q{font-weight:600;font-size:.9rem;margin-bottom:.3rem;}"); + s.append(".faq-a{color:var(--text2);font-size:.85rem;line-height:1.6;}"); + s.append(".faq-actions{display:flex;gap:.375rem;flex-shrink:0;}"); + + // Modal + s.append(".modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:200;align-items:center;justify-content:center;backdrop-filter:blur(4px);}"); + s.append(".modal-backdrop.open{display:flex;}"); + s.append(".modal{background:var(--surface);border:1px solid var(--border2);border-radius:14px;padding:1.75rem;width:100%;max-width:500px;box-shadow:var(--shadow);animation:modal-in .15s ease;}"); + s.append("@keyframes modal-in{from{opacity:0;transform:scale(.96) translateY(8px)}to{opacity:1;transform:none}}"); + s.append(".modal-title{font-size:1rem;font-weight:700;margin-bottom:1.25rem;letter-spacing:-.01em;}"); + s.append(".modal-actions{display:flex;gap:.625rem;justify-content:flex-end;margin-top:1.25rem;}"); + + // Divider + s.append(".divider{height:1px;background:var(--border);margin:1.25rem 0;}"); + + // Back link + s.append(".back-link{display:inline-flex;align-items:center;gap:.4rem;color:var(--text3);text-decoration:none;font-size:.825rem;margin-bottom:1.25rem;transition:color .15s;}"); + s.append(".back-link:hover{color:var(--text);}"); + + // Empty state + s.append(".empty{text-align:center;padding:3rem 1rem;color:var(--text3);}"); + s.append(".empty-icon{font-size:2.5rem;margin-bottom:.75rem;}"); + s.append(".empty-text{font-size:.9rem;}"); + + // Responsive + s.append("@media(max-width:700px){"); + s.append(".navbar{padding:0 1rem;gap:1rem;}"); + s.append(".container{padding:1.25rem 1rem;}"); + s.append(".info-grid{grid-template-columns:1fr;}"); + s.append(".stats-grid{grid-template-columns:repeat(2,1fr);}"); + s.append(".search-wrap{margin-left:0;width:100%;}"); + s.append(".search-wrap input{width:100%;}"); + s.append("}"); + + return s.toString(); + } + + // ── JavaScript ──────────────────────────────────────────────────────────── + + private static String buildJs() { + StringBuilder s = new StringBuilder(); + + // API + s.append("async function api(path,method,body){"); + s.append("if(!method)method='GET';"); + s.append("var opts={method:method,headers:{'Content-Type':'application/json'}};"); + s.append("if(body)opts.body=JSON.stringify(body);"); + s.append("var r=await fetch('/api'+path,opts);"); + s.append("return r.json();"); + s.append("}"); + + // Modal + s.append("function openModal(id){document.getElementById(id).classList.add('open');}"); + s.append("function closeModal(id){document.getElementById(id).classList.remove('open');}"); + s.append("document.addEventListener('keydown',function(e){"); + s.append("if(e.key==='Escape'){document.querySelectorAll('.modal-backdrop.open').forEach(function(m){m.classList.remove('open');});}"); + s.append("});"); + + // Toast + s.append("function toast(msg,type){"); + s.append("var t=document.createElement('div');"); + s.append("t.style.cssText='position:fixed;bottom:1.5rem;right:1.5rem;padding:.75rem 1.25rem;border-radius:8px;font-size:.85rem;font-weight:500;z-index:9999;animation:modal-in .2s ease;max-width:320px;';"); + s.append("if(type==='error'){t.style.background='rgba(239,68,68,.15)';t.style.border='1px solid rgba(239,68,68,.3)';t.style.color='#fca5a5';}"); + s.append("else{t.style.background='rgba(34,197,94,.15)';t.style.border='1px solid rgba(34,197,94,.3)';t.style.color='#86efac';}"); + s.append("t.textContent=msg;document.body.appendChild(t);"); + s.append("setTimeout(function(){t.remove();},3000);"); + s.append("}"); + + // Ticket: claim + s.append("async function claimTicket(id){"); + s.append("if(!confirm('Ticket #'+id+' claimen?'))return;"); + s.append("var r=await api('/ticket/'+id+'/claim','POST');"); + s.append("if(r.ok){location.reload();}else{toast('Fehler: '+r.error,'error');}"); + s.append("}"); + + // Ticket: close + s.append("async function closeTicket(id){"); + s.append("var el=document.getElementById('close-comment-'+id);"); + s.append("var comment=el?el.value:'';"); + s.append("var r=await api('/ticket/'+id+'/close','POST',{comment:comment});"); + s.append("if(r.ok){location.reload();}else{toast('Fehler: '+r.error,'error');}"); + s.append("}"); + + // Ticket: priority + s.append("async function setPriority(id,prio){"); + s.append("var r=await api('/ticket/'+id+'/priority','POST',{priority:prio});"); + s.append("if(r.ok){location.reload();}else{toast('Fehler: '+r.error,'error');}"); + s.append("}"); + + // Ticket: comment + s.append("async function addComment(id){"); + s.append("var el=document.getElementById('comment-input-'+id);"); + s.append("var msg=el?el.value:'';"); + s.append("if(!msg.trim())return;"); + s.append("var btn=document.getElementById('comment-btn-'+id);"); + s.append("if(btn)btn.disabled=true;"); + s.append("var r=await api('/ticket/'+id+'/comment','POST',{message:msg});"); + s.append("if(r.ok){location.reload();}else{toast('Fehler: '+r.error,'error');if(btn)btn.disabled=false;}"); + s.append("}"); + + // Ticket: forward + s.append("async function forwardTicket(id){"); + s.append("var el=document.getElementById('forward-target-'+id);"); + s.append("var target=el?el.value:'';"); + s.append("if(!target.trim()){toast('Spielername angeben','error');return;}"); + s.append("var r=await api('/ticket/'+id+'/forward','POST',{target:target});"); + s.append("if(r.ok){location.reload();}else{toast('Fehler: '+r.error,'error');}"); + s.append("}"); + + // FAQ: delete + s.append("async function deleteFaq(id){"); + s.append("if(!confirm('FAQ #'+id+' wirklich loeschen?'))return;"); + s.append("var r=await api('/faq/'+id,'DELETE');"); + s.append("if(r.ok){location.reload();}else{toast('Fehler: '+r.error,'error');}"); + s.append("}"); + + // FAQ: open edit + s.append("function openFaqEdit(id,question,answer,cat){"); + s.append("document.getElementById('edit-faq-id').value=id;"); + s.append("document.getElementById('edit-faq-q').value=question;"); + s.append("document.getElementById('edit-faq-a').value=answer;"); + s.append("var c=document.getElementById('edit-faq-cat');if(c)c.value=cat;"); + s.append("openModal('modal-edit-faq');"); + s.append("}"); + + // FAQ: submit edit + s.append("async function submitFaqEdit(){"); + s.append("var id=document.getElementById('edit-faq-id').value;"); + s.append("var q=document.getElementById('edit-faq-q').value;"); + s.append("var a=document.getElementById('edit-faq-a').value;"); + s.append("var c=document.getElementById('edit-faq-cat');var cat=c?c.value:'';"); + s.append("var r=await api('/faq/'+id,'PUT',{question:q,answer:a,category:cat});"); + s.append("if(r.ok){closeModal('modal-edit-faq');location.reload();}else{toast('Fehler: '+r.error,'error');}"); + s.append("}"); + + // FAQ: submit add + s.append("async function submitFaqAdd(){"); + s.append("var q=document.getElementById('new-faq-q').value;"); + s.append("var a=document.getElementById('new-faq-a').value;"); + s.append("var c=document.getElementById('new-faq-cat');var cat=c?c.value:'';"); + s.append("var r=await api('/faq','POST',{question:q,answer:a,category:cat});"); + s.append("if(r.ok){closeModal('modal-add-faq');location.reload();}else{toast('Fehler: '+r.error,'error');}"); + s.append("}"); + + // Filter persist + s.append("document.addEventListener('DOMContentLoaded',function(){"); + s.append("document.querySelectorAll('.filter-bar select').forEach(function(sel){"); + s.append("sel.addEventListener('change',function(){"); + s.append("var url=new URL(location.href);"); + s.append("url.searchParams.set(sel.name,sel.value);"); + s.append("url.searchParams.delete('page');"); + s.append("location.href=url.toString();"); + s.append("});"); + s.append("});"); + // Active nav link + s.append("var path=location.pathname;"); + s.append("document.querySelectorAll('.nav-link').forEach(function(a){"); + s.append("if(a.getAttribute('href')===path||(path.startsWith('/ticket')&&a.getAttribute('href')==='/tickets')){"); + s.append("a.classList.add('active');"); + s.append("}"); + s.append("});"); + s.append("});"); + + return s.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/de/ticketsystem/web/handlers/TicketsHandler.java b/src/main/java/de/ticketsystem/web/handlers/TicketsHandler.java new file mode 100644 index 0000000..bc5595b --- /dev/null +++ b/src/main/java/de/ticketsystem/web/handlers/TicketsHandler.java @@ -0,0 +1,379 @@ +package de.ticketsystem.web.handlers; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import de.ticketsystem.TicketPlugin; +import de.ticketsystem.database.DatabaseManager; +import de.ticketsystem.model.*; +import de.ticketsystem.web.SessionManager; +import de.ticketsystem.web.WebSession; + +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.stream.Collectors; + +/** + * /tickets – Liste aller Tickets (gefiltert, paginiert) + * /ticket/{id} – Detailansicht eines Tickets + */ +public class TicketsHandler extends BaseHandler implements HttpHandler { + + private static final int PAGE_SIZE = 20; + private final SimpleDateFormat SDF = new SimpleDateFormat("dd.MM.yyyy HH:mm"); + + private final TicketPlugin plugin; + private final SessionManager sessionManager; + + public TicketsHandler(TicketPlugin plugin, SessionManager sessionManager) { + this.plugin = plugin; + this.sessionManager = sessionManager; + } + + @Override + public void handle(HttpExchange ex) throws IOException { + WebSession session = requireSession(ex, sessionManager); + if (session == null) return; + + String path = ex.getRequestURI().getPath(); + + if (path.startsWith("/ticket/") && path.length() > 8) { + String idStr = path.substring(8).replaceAll("[^0-9]", ""); + if (!idStr.isEmpty()) { + handleDetail(ex, session, Integer.parseInt(idStr)); + return; + } + } + + handleList(ex, session); + } + + // ─────────────────────────── Ticket-Liste ────────────────────────────── + + private void handleList(HttpExchange ex, WebSession session) throws IOException { + Map params = parseQuery(ex.getRequestURI().getQuery()); + + String filterStatus = params.getOrDefault("status", "all"); + String filterCat = params.getOrDefault("category", "all"); + String filterPrio = params.getOrDefault("priority", "all"); + String filterSearch = params.getOrDefault("q", "").trim(); + int page = parseInt(params.getOrDefault("page", "1"), 1); + + DatabaseManager db = plugin.getDatabaseManager(); + List all = db.getAllTickets(); + + // ── Filter ── + List filtered = all.stream() + .filter(t -> filterStatus.equals("all") || t.getStatus().name().equalsIgnoreCase(filterStatus)) + .filter(t -> filterCat.equals("all") || t.getCategoryKey().equalsIgnoreCase(filterCat)) + .filter(t -> filterPrio.equals("all") || t.getPriority().name().equalsIgnoreCase(filterPrio)) + .filter(t -> filterSearch.isEmpty() + || t.getCreatorName().toLowerCase().contains(filterSearch.toLowerCase()) + || t.getMessage().toLowerCase().contains(filterSearch.toLowerCase()) + || String.valueOf(t.getId()).contains(filterSearch)) + .sorted(Comparator.comparingInt(Ticket::getId).reversed()) + .collect(Collectors.toList()); + + // ── Paginierung ── + int total = filtered.size(); + int totalPages = Math.max(1, (int) Math.ceil(total / (double) PAGE_SIZE)); + page = Math.max(1, Math.min(page, totalPages)); + int fromIdx = (page - 1) * PAGE_SIZE; + int toIdx = Math.min(fromIdx + PAGE_SIZE, total); + List pageTickets = filtered.subList(fromIdx, toIdx); + + String content = buildList(pageTickets, total, page, totalPages, + filterStatus, filterCat, filterPrio, filterSearch); + sendHtml(ex, 200, layout(wl(plugin, "tickets-title"), content, session, plugin)); + } + + private String buildList(List tickets, int total, int page, int totalPages, + String filterStatus, String filterCat, String filterPrio, String filterSearch) { + StringBuilder sb = new StringBuilder(); + sb.append("

") + .append(escHtml(wl(plugin, "tickets-title"))) + .append(" ").append(total).append(" ") + .append(escHtml(wl(plugin, "tickets-total"))) + .append("

"); + + // ── Filter-Bar ── + sb.append("
"); + sb.append(selectFilter("status", filterStatus, List.of( + entry("all", wl(plugin, "filter-all-status")), + entry("OPEN", wl(plugin, "filter-open")), + entry("CLAIMED", wl(plugin, "filter-claimed")), + entry("FORWARDED", wl(plugin, "filter-forwarded")), + entry("CLOSED", wl(plugin, "filter-closed"))))); + + if (plugin.getConfig().getBoolean("categories-enabled", true)) { + List> catOpts = new ArrayList<>(); + catOpts.add(entry("all", wl(plugin, "filter-all-cat"))); + plugin.getCategoryManager().getAll().forEach(c -> catOpts.add(entry(c.getKey(), c.getName()))); + sb.append(selectFilter("category", filterCat, catOpts)); + } + + if (plugin.getConfig().getBoolean("priorities-enabled", true)) { + sb.append(selectFilter("priority", filterPrio, List.of( + entry("all", wl(plugin, "filter-all-prio")), + entry("LOW", wl(plugin, "filter-low")), + entry("NORMAL", wl(plugin, "filter-normal")), + entry("HIGH", wl(plugin, "filter-high")), + entry("URGENT", wl(plugin, "filter-urgent"))))); + } + + sb.append("
"); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append("
"); + sb.append("
"); + + // ── Tabelle ── + sb.append("
") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append(""); + + for (Ticket t : tickets) { + String msg = t.getMessage() != null && t.getMessage().length() > 60 + ? t.getMessage().substring(0, 60) + "…" : t.getMessage(); + String created = t.getCreatedAt() != null ? SDF.format(t.getCreatedAt()) : "—"; + String catName = getCategoryName(t); + + sb.append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append(""); + } + + if (tickets.isEmpty()) { + sb.append(""); + } + + sb.append("
").append(escHtml(wl(plugin, "tickets-col-id"))).append("").append(escHtml(wl(plugin, "tickets-col-player"))).append("").append(escHtml(wl(plugin, "tickets-col-message"))).append("").append(escHtml(wl(plugin, "tickets-col-cat"))).append("").append(escHtml(wl(plugin, "tickets-col-prio"))).append("").append(escHtml(wl(plugin, "tickets-col-status"))).append("").append(escHtml(wl(plugin, "tickets-col-created"))).append("
#").append(t.getId()).append("").append(escHtml(t.getCreatorName())).append("").append(escHtml(msg)).append("").append(escHtml(catName)).append("").append(priorityBadge(t)).append("").append(statusBadge(t)).append("").append(created).append("") + .append(escHtml(wl(plugin, "btn-details"))) + .append("
") + .append(escHtml(wl(plugin, "tickets-empty"))) + .append("
"); + + // ── Paginierung ── + if (totalPages > 1) { + sb.append(""); + } + + return sb.toString(); + } + + // ─────────────────────────── Ticket-Detail ───────────────────────────── + + private void handleDetail(HttpExchange ex, WebSession session, int ticketId) throws IOException { + Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId); + if (ticket == null) { + sendHtml(ex, 404, errorPage( + wl(plugin, "detail-not-found"), + wl(plugin, "detail-not-found") + " #" + ticketId, + plugin)); + return; + } + + List comments = plugin.getDatabaseManager().getComments(ticketId); + String content = buildDetail(ticket, comments, session); + sendHtml(ex, 200, layout("Ticket #" + ticketId, content, session, plugin)); + } + + private String buildDetail(Ticket t, List comments, WebSession session) { + StringBuilder sb = new StringBuilder(); + String created = t.getCreatedAt() != null ? SDF.format(t.getCreatedAt()) : "—"; + String catName = getCategoryName(t); + + // ── Header ── + sb.append(""); + sb.append("
"); + sb.append("
#").append(t.getId()).append("
"); + sb.append("
"); + sb.append(statusBadge(t)).append(" ").append(priorityBadge(t)); + sb.append("") + .append(escHtml(catName)).append(""); + if (plugin.isBungeeCordEnabled()) { + sb.append("🌐 ") + .append(escHtml(t.getServerName())).append(""); + } + sb.append("
"); + + // ── Info-Grid ── + sb.append("
"); + infoRow(sb, wl(plugin, "detail-info-creator"), t.getCreatorName()); + infoRow(sb, wl(plugin, "detail-info-created"), created); + infoRow(sb, wl(plugin, "detail-info-claimer"), t.getClaimerName() != null ? t.getClaimerName() : "—"); + infoRow(sb, wl(plugin, "detail-info-forwarded"), t.getForwardedToName() != null ? t.getForwardedToName() : "—"); + if (t.getWorldName() != null) { + infoRow(sb, wl(plugin, "detail-info-position"), String.format("%s %.0f / %.0f / %.0f", + t.getWorldName(), t.getX(), t.getY(), t.getZ())); + } + if (t.getPlayerRating() != null) { + String ratingText = "THUMBS_UP".equals(t.getPlayerRating()) + ? wl(plugin, "detail-rating-pos") + : wl(plugin, "detail-rating-neg"); + infoRow(sb, wl(plugin, "detail-info-rating"), ratingText); + } + sb.append("
"); + + // ── Nachricht ── + sb.append("
").append(escHtml(wl(plugin, "detail-section-msg"))).append("
"); + sb.append("
").append(escHtml(t.getMessage())).append("
"); + + if (t.getCloseComment() != null && !t.getCloseComment().isEmpty()) { + sb.append("
").append(escHtml(wl(plugin, "detail-section-closecomment"))).append("
"); + sb.append("
").append(escHtml(t.getCloseComment())).append("
"); + } + + // ── Aktionen (nur bei aktiven Tickets) ── + if (t.getStatus() != TicketStatus.CLOSED) { + sb.append("
") + .append(escHtml(wl(plugin, "detail-section-actions"))) + .append("
"); + + // Claim + if (t.getStatus() == TicketStatus.OPEN) { + sb.append(""); + } + + // Priorität + if (plugin.getConfig().getBoolean("priorities-enabled", true)) { + sb.append(""); + } + + // Weiterleiten (nur Admin) + if (session.isAdmin()) { + sb.append(""); + sb.append(""); + } + + // Schließen + sb.append("
"); + sb.append(""); + sb.append(""); + sb.append("
"); + + sb.append("
"); + } + + // ── Kommentare ── + sb.append("
") + .append(escHtml(wl(plugin, "detail-section-comments"))) + .append(" (").append(comments.size()).append(")
"); + sb.append("
"); + for (TicketComment c : comments) { + String cTime = c.getCreatedAt() != null ? SDF.format(c.getCreatedAt()) : ""; + sb.append("
"); + sb.append("").append(escHtml(c.getAuthorName())).append(""); + sb.append("").append(cTime).append(""); + sb.append("
").append(escHtml(c.getMessage())).append("
"); + sb.append("
"); + } + if (comments.isEmpty()) { + sb.append("
") + .append(escHtml(wl(plugin, "detail-no-comments"))).append("
"); + } + sb.append("
"); + + // Neuer Kommentar + if (t.getStatus() != TicketStatus.CLOSED) { + sb.append("
"); + sb.append(""); + sb.append(""); + sb.append("
"); + } + sb.append("
"); + + return sb.toString(); + } + + // ─────────────────────────── Hilfsmethoden ───────────────────────────── + + private String statusBadge(Ticket t) { + String s = t.getStatus().name().toLowerCase(); + return "" + escHtml(t.getStatus().getDisplayName()) + ""; + } + + private String priorityBadge(Ticket t) { + String p = t.getPriority().name().toLowerCase(); + return "" + escHtml(t.getPriority().getDisplayName()) + ""; + } + + private String getCategoryName(Ticket t) { + var cat = plugin.getCategoryManager().fromKey(t.getCategoryKey()); + return cat != null ? cat.getName() : t.getCategoryKey(); + } + + private void infoRow(StringBuilder sb, String label, String value) { + sb.append("
").append(escHtml(label)).append("") + .append("").append(escHtml(value != null ? value : "—")).append("
"); + } + + private String selectFilter(String name, String current, List> options) { + StringBuilder sb = new StringBuilder("").toString(); + } + + private static Map.Entry entry(String k, String v) { + return Map.entry(k, v); + } + + private static int parseInt(String s, int fallback) { + try { return Integer.parseInt(s); } catch (NumberFormatException e) { return fallback; } + } +} \ No newline at end of file diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0fb8acb..1e7ddf2 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -14,7 +14,7 @@ # --- GRUNDLEGEND --- # Version der Konfigurationsdatei. Nicht ändern! -version: "2.2" +version: "2.4" # ---------------------------------------------------- # SPRACHE / LANGUAGE @@ -100,36 +100,20 @@ cache-ttl-seconds: 60 # Wie lange Tickets im In-Memory-Cache gehalten # ---------------------------------------------------- # Kategorie-System (true = aktiviert) -# Spieler können beim Erstellen eine Kategorie wählen: /ticket create [kategorie] [priorität] categories-enabled: true # Prioritäten-System (true = aktiviert) -# Spieler können beim Erstellen eine Priorität wählen: /ticket create [kategorie] [priorität] -# Admins/Supporter können die Priorität nachträglich ändern: /ticket setpriority priorities-enabled: true # Dürfen normale Spieler beim Erstellen eine Priorität setzen? -# false = Nur Admins/Supporter können Prioritäten setzen (ticket.support, ticket.admin) -# true = Spieler können beim /ticket create dabei Prioritäten angeben allow-players-to-set-priority: false # Bewertungs-System (true = aktiviert) -# Spieler können nach dem Schließen den Support bewerten: /ticket rate good|bad -# Ergebnisse sind in /ticket stats sichtbar rating-enabled: true # ---------------------------------------------------- # KATEGORIEN (nur aktiv wenn categories-enabled: true) # ---------------------------------------------------- -# Jede Kategorie hat: -# name: Anzeigename im Chat und in der GUI -# color: Farbcode mit & (Minecraft Farbcodes) -# material: Minecraft-Material für das GUI-Item (Großbuchstaben, z.B. PAPER, REDSTONE, BOOK) -# aliases: Alternative Eingaben beim /ticket create Befehl (Kleinbuchstaben!) -# -# Das erste eingetragene Item ist die Standard-Kategorie für Tickets ohne Angabe. -# Du kannst beliebig viele Kategorien hinzufügen oder entfernen. -# ---------------------------------------------------- categories: general: name: "Allgemein" @@ -240,13 +224,24 @@ gui-settings: # Beispiel: content-slots: [1, 3, 5, 7, 10, 12, 14, 16] -> Nur ungerade Slots content-slots: [] - # Kopfeinstellungen + # Kopfeinstellungen für FAQ-EINTRÄGE head-item: # Material des FAQ-Items (z.B. PLAYER_HEAD, BOOK, PAPER) material: PLAYER_HEAD # Optional: Texture-URL für den Kopf (wenn Material PLAYER_HEAD) texture: "http://textures.minecraft.net/texture/da2fde34d34c8588e58bfd790ce18025f7843399dee2ab4cedc2c0b463fd1e" + # Kopfeinstellungen für FAQ-KATEGORIEN (Kategorie-Auswahl-Screen) + # Alle Kategorien nutzen denselben Kopf – nur relevant wenn in faqs.yml + # Kategorien definiert sind. + category-head-item: + # Material des Kategorie-Items (z.B. PLAYER_HEAD, BOOK, CHEST) + # Bei PLAYER_HEAD wird die texture-URL verwendet. + material: PLAYER_HEAD + # Texture-URL für den Kategorie-Kopf. + # Leer lassen ("") = Standard-Spielerkopf ohne Custom-Skin. + texture: "http://textures.minecraft.net/texture/2867f1d66d76bb2952e7a82f0dcf8eac5ae0e035a646db07a72b2d668df7cff2" + # Navigations-Slots (Prev, Next, Add, Page) nav: prev: 45 @@ -291,4 +286,53 @@ gui-settings: nav-archive: CHEST nav-back: ARROW nav-filter: HOPPER - nav-add: LIME_WOOL \ No newline at end of file + nav-add: LIME_WOOL + +# ============================================================ +# WEB-PANEL +# ============================================================ +# Ermöglicht die Verwaltung von Tickets über den Browser. +# Zugriff: http://: +# +# FIREWALL: Port muss freigegeben sein! +# +# SICHERHEIT: +# - Passwörter werden beim ersten Start automatisch als +# SHA-256-Hash gespeichert (Klartext wird danach entfernt). +# - Sessions laufen nach session-timeout-minutes Inaktivität ab. +# - bind-address auf "127.0.0.1" setzen wenn nur lokaler +# Zugriff gewünscht ist (z.B. hinter Nginx/Apache). +# ============================================================ +web-panel: + enabled: false + port: 8085 + + # Bind-Adresse: + # "0.0.0.0" = alle Interfaces (direkt erreichbar) + # "127.0.0.1" = nur lokal (Reverse-Proxy empfohlen) + bind-address: "0.0.0.0" + + # Session-Timeout in Minuten (nach Inaktivität) + session-timeout-minutes: 60 + + logo-file: "logo.png" # Datei in plugins/TicketSystem/ ablegen + brand-name: "TicketSystem" # optional: eigener Name links + tagline: "Verwalte Support-Tickets..." # optional: eigener Untertitel + + # ── Benutzer ────────────────────────────────────────────── + # Rollen: + # admin → Voller Zugriff: Tickets, FAQ, Weiterleiten + # supporter → Tickets anzeigen, claimen, schließen, kommentieren + # + # Passwort setzen: + # Trage "password: deinPasswort" ein. + # Beim nächsten Serverstart wird es automatisch gehasht + # und durch "password-hash: ..." ersetzt. + # ────────────────────────────────────────────────────────── + users: + admin: + password: "aendere_mich" + role: "admin" + supporter: + password: "aendere_mich" + role: "supporter" \ No newline at end of file diff --git a/src/main/resources/lang_de.yml b/src/main/resources/lang_de.yml index 3d6bf6a..9998798 100644 --- a/src/main/resources/lang_de.yml +++ b/src/main/resources/lang_de.yml @@ -237,6 +237,7 @@ faq: created: "&aFAQ &e#{id} &awurde erfolgreich erstellt!" created-question: "&7Frage: &e{question}" created-answer: "&7Antwort: &f{answer}" + created-category-info: "&7Kategorie: {category}" updated: "&aFAQ &e#{id} &awurde erfolgreich aktualisiert!" deleted: "&aFAQ &e#{id} &awurde gelöscht." not-found: "&cFAQ &e#{id} &cwurde nicht gefunden." @@ -250,6 +251,21 @@ faq: hint-open: "&7Benutze &e{cmd_faq} &7zum Öffnen der GUI." admin-commands: "&7Admin-Befehle: &e{cmd_faq} add | edit | delete | reload | list" +# ============================================================ +# FAQ-KATEGORIEN BEFEHL (/ticket kategorie) +# ============================================================ +faqcat: + usage: "&cBenutzung: /ticket kategorie " + usage-add: "&cBenutzung: /ticket kategorie add [&Farbe] [Beschreibung]" + usage-delete: "&cBenutzung: /ticket kategorie delete " + created: "&aFAQ-Kategorie &e{name} &aerstellt! &7(Schlüssel: &e{key}&7)" + deleted: "&aFAQ-Kategorie &e{key} &awurde gelöscht. &7FAQs sind nun unkategorisiert." + not-found: "&cFAQ-Kategorie &e{key} &cwurde nicht gefunden." + already-exists: "&cEine FAQ-Kategorie mit dem Schlüssel &e{name} &cexistiert bereits." + list-header: "&6FAQ-Kategorien &7({count} Einträge)" + list-empty: "&7Noch keine FAQ-Kategorien vorhanden." + list-entry: "&e{key} &8→ &r{name} &8| &7{count} FAQ(s) &8| &7{desc}" + # ============================================================ # HILFE-MENÜ (/ticket ohne Argumente) # ============================================================ @@ -407,9 +423,9 @@ gui: nav-filter-click: "§8Klicken zum Wechseln" nav-filter-all: "§7Alle (kein Filter)" - # ── FAQ GUI Texte (Neu) ───────────────────────────────── + # ── FAQ GUI Texte ─────────────────────────────────────── faq: - title: "&#FFD700&lHäufige Fragen (FAQ)" + title: "&#FFD700&lHäufige Fragen (FAQ)" admin-title: "§8§lFAQ verwalten" action-title: "§8§lFAQ Aktionen" @@ -424,32 +440,84 @@ gui: delete-button: "§c§lFAQ löschen" delete-lore-1: "§7Löscht diesen FAQ-Eintrag." delete-lore-2: "§c§lACHTUNG: §cNicht rückgängig zu machen!" - delete-error: "§cFehler: FAQ #{id} konnte nicht gelöscht werden." + delete-error: "§cFehler: FAQ #{id} konnte nicht gelöscht werden." back-button: "§7§lZurück" - back-lore: "§7Zurück zur FAQ-Übersicht." + back-lore: "§7Zurück zur FAQ-Übersicht." click-detail: "§e» Klicken für mehr Details im Chat" - click-edit: "§e» Klicken zum Bearbeiten / Löschen" + click-edit: "§e» Klicken zum Bearbeiten / Löschen" - nav-prev: "§7§l◄ Zurück" + nav-prev: "§7§l◄ Zurück" nav-prev-lore: "§7Seite {page} von {total}" - nav-next: "§7§lWeiter ►" + nav-next: "§7§lWeiter ►" nav-next-lore: "§7Seite {page} von {total}" - nav-page: "§8Seite {page}/{total}" + nav-page: "§8Seite {page}/{total}" nav-page-lore: "§7Gesamt: {count} FAQ(s)" - chat-create-title: "§6§lNeues FAQ erstellen" + chat-create-title: "§6§lNeues FAQ erstellen" chat-question-prompt: "§7Gib die §eFrage §7ein (oder §ccancel§7):" - chat-answer-prompt: "§7Gib jetzt die §eAntwort §7ein (oder §ccancel§7):" - chat-edit-title: "§6§lFAQ #{id} bearbeiten" + chat-answer-prompt: "§7Gib jetzt die §eAntwort §7ein (oder §ccancel§7):" + chat-edit-title: "§6§lFAQ #{id} bearbeiten" chat-current-question: "§7Aktuelle Frage: §e{question}" - lore-id: "§7FAQ #{id}" + lore-id: "§7FAQ #{id}" lore-separator: "§8§m " - question-set: "§7Frage gesetzt: §e{question}" + lore-category: "§7Kategorie: {category}" + question-set: "§7Frage gesetzt: §e{question}" internal-error: "§cInterner Fehler beim Bearbeiten des FAQs." + # ── Kategorie-Auswahl-Screen ──────────────────────── + cat-title: "&#FFD700&lFAQ – Kategorie wählen" + cat-admin-title: "§8§lFAQ – Kategorie verwalten" + + cat-lore-separator: "§8§m " + cat-lore-count: "§7Einträge: §e{count}" + cat-lore-click: "§e» Klicken zum Öffnen" + cat-lore-admin-hint: "§8(Admin: Neue FAQ in diese Kategorie)" + cat-lore-shift-hint: "§8Shift+Klick zum Verwalten" + + cat-back-button: "§7§l◄ Kategorien" + cat-back-lore: "§7Zurück zur Kategorieauswahl." + + cat-add-button: "§a§l+ Neue Kategorie" + cat-add-lore-1: "§7Erstellt eine neue FAQ-Kategorie." + cat-add-lore-2: "§7Nutze: /ticket kategorie add " + + # ── Kategorie-Aktions-GUI ──────────────────────────── + cat-action-title: "§8§lKategorie verwalten" + cat-edit-button: "§a§lKategorie bearbeiten" + cat-edit-lore-1: "§7Ändert Name, Farbe und" + cat-edit-lore-2: "§7Beschreibung dieser Kategorie." + cat-delete-button: "§c§lKategorie löschen" + cat-delete-lore-1: "§7Löscht diese Kategorie." + cat-delete-lore-2: "§7FAQs werden auf 'Ohne Kategorie' gesetzt." + + # ── Chat-Flow Kategorie ────────────────────────────── + cat-chat-add-title: "§6§lNeue Kategorie erstellen" + cat-chat-edit-title: "§6§lKategorie bearbeiten: {name}" + cat-chat-current-name: "§7Aktueller Name: §e{name}" + cat-chat-current-color: "§7Aktuelle Farbe: {color}" + cat-chat-current-desc: "§7Aktuelle Beschreibung: §f{desc}" + cat-chat-name-prompt: "§7Gib den §eNamen §7ein (oder §ccancel§7):" + cat-chat-name-set: "§7Name gesetzt: §e{name}" + cat-chat-color-prompt: "§7Gib den §eFarbcode §7ein, z.B. §a&a§7 oder §c&c §7(oder §ccancel§7):" + cat-chat-color-set: "§7Farbe gesetzt: {colored}" + cat-chat-desc-prompt: "§7Gib die §eBeschreibung §7ein (§8- §7für keine, oder §ccancel§7):" + + cat-created: "§aKategorie §e{name} §awurde erstellt!" + cat-updated: "§aKategorie §e{name} §awurde aktualisiert!" + cat-deleted: "§aKategorie §e{name} §awurde gelöscht." + cat-already-exists: "§cEine Kategorie mit dem Schlüssel '§e{name}§c' existiert bereits." + cat-not-found: "§cKategorie '§e{name}§c' wurde nicht gefunden." + + # ── Dynamische Titel mit Kategorie ─────────────────── + title-cat: "&#FFD700&lFAQ – {category}" + admin-title-cat: "§8§lFAQ – {category}" + title-cat-prefix: "&#FFD700&lFAQ" + admin-title-cat-prefix: "§8§lFAQ" + chat-category-hint: "§7Kategorie: {category}" + # ============================================================ # JOIN-LISTENER # ============================================================ @@ -467,4 +535,119 @@ update: available-console: "Neue Version verfügbar: {new} (aktuell: {current})" available-bar: "====================================================" available-line1: "&6[TicketSystem] &eNEUES UPDATE VERFÜGBAR: v{version}" - available-line2: "&6[TicketSystem] &eDownload: https://www.spigotmc.org/resources/132757" \ No newline at end of file + available-line2: "&6[TicketSystem] &eDownload: https://www.spigotmc.org/resources/132757" +# ============================================================ +# WEB-PANEL TEXTE (Benutzeroberfläche im Browser) +# ============================================================ +web: + # ── Navigation ──────────────────────────────────────────── + nav-dashboard: "Dashboard" + nav-tickets: "Tickets" + nav-faq: "FAQ" + nav-logout: "Abmelden" + role-admin: "Admin" + role-supporter: "Supporter" + btn-back: "Zurück" + btn-details: "Details" + + # ── Fehlerseiten ────────────────────────────────────────── + error-403-title: "403 – Kein Zugriff" + error-403-message: "Diese Seite ist nur für Administratoren zugänglich." + + # ── Login-Seite ─────────────────────────────────────────── + login-title: "Login – TicketSystem Panel" + login-heading: "Willkommen zurück" + login-sub: "Melde dich mit deinem Account an, um fortzufahren." + login-error: "Benutzername oder Passwort falsch." + login-label-user: "Benutzername" + login-label-pass: "Passwort" + login-btn: "Anmelden" + login-footer: "TicketSystem Panel · Nur für Supporter & Admins" + + # ── Dashboard ───────────────────────────────────────────── + dash-title: "Dashboard" + dash-stat-total: "Tickets gesamt" + dash-stat-open: "Offen / Aktiv" + dash-stat-closed: "Geschlossen" + dash-stat-thumbsup: "👍 Bewertungen" + dash-stat-thumbsdown: "👎 Bewertungen" + dash-stat-rating: "Zufriedenheit" + dash-section-recent: "Aktuelle Tickets" + dash-section-top: "Top Ticket-Ersteller" + dash-section-server: "Tickets pro Server" + dash-empty: "Keine offenen Tickets 🎉" + dash-col-id: "#" + dash-col-player: "Spieler" + dash-col-category: "Kategorie" + dash-col-priority: "Priorität" + dash-col-status: "Status" + dash-col-created: "Erstellt" + dash-col-server: "Server" + dash-col-count: "Tickets" + + # ── Ticket-Liste ────────────────────────────────────────── + tickets-title: "Tickets" + tickets-total: "gesamt" + tickets-empty: "Keine Tickets gefunden" + tickets-search-ph: "Suche…" + tickets-col-id: "#" + tickets-col-player: "Spieler" + tickets-col-message: "Nachricht" + tickets-col-cat: "Kategorie" + tickets-col-prio: "Priorität" + tickets-col-status: "Status" + tickets-col-created: "Erstellt" + filter-all-status: "Alle Status" + filter-open: "Offen" + filter-claimed: "Angenommen" + filter-forwarded: "Weitergeleitet" + filter-closed: "Geschlossen" + filter-all-cat: "Alle Kategorien" + filter-all-prio: "Alle Prioritäten" + filter-low: "Niedrig" + filter-normal: "Normal" + filter-high: "Hoch" + filter-urgent: "Dringend" + + # ── Ticket-Detail ───────────────────────────────────────── + detail-not-found: "Ticket nicht gefunden" + detail-back: "← Zurück" + detail-info-creator: "Ersteller" + detail-info-created: "Erstellt" + detail-info-claimer: "Bearbeiter" + detail-info-forwarded: "Weitergeleitet an" + detail-info-position: "Position" + detail-info-rating: "Bewertung" + detail-rating-pos: "👍 Positiv" + detail-rating-neg: "👎 Negativ" + detail-section-msg: "Nachricht" + detail-section-closecomment: "Schließ-Kommentar" + detail-section-actions: "Aktionen" + detail-section-comments: "Kommentare" + detail-btn-claim: "✅ Claimen" + detail-btn-forward: "🔀 Weiterleiten" + detail-btn-close: "🔒 Schließen" + detail-ph-forward: "Spielername…" + detail-ph-comment: "Kommentar (optional)" + detail-ph-newcomment: "Kommentar schreiben…" + detail-btn-send: "Senden" + detail-no-comments: "Noch keine Kommentare." + + # ── FAQ-Verwaltung ──────────────────────────────────────── + faq-title: "FAQ-Verwaltung" + faq-entries-suffix: "Einträge" + faq-section-cats: "Kategorien" + faq-no-category: "Ohne Kategorie" + faq-btn-add: "+ FAQ hinzufügen" + faq-empty: "Noch keine FAQ-Einträge." + faq-no-cat-opt: "Keine Kategorie" + faq-modal-add-title: "FAQ hinzufügen" + faq-modal-edit-title: "FAQ bearbeiten" + faq-label-question: "Frage" + faq-label-answer: "Antwort" + faq-label-category: "Kategorie" + faq-ph-question: "Frage…" + faq-ph-answer: "Antwort…" + faq-btn-cancel: "Abbrechen" + faq-btn-save: "Speichern" + faq-btn-add-confirm: "Hinzufügen" \ No newline at end of file diff --git a/src/main/resources/lang_en.yml b/src/main/resources/lang_en.yml index 399fbdf..b4774f5 100644 --- a/src/main/resources/lang_en.yml +++ b/src/main/resources/lang_en.yml @@ -6,13 +6,12 @@ # HEX-Codes: &#RRGGBB (e.g. �AA00 = Green) # Placeholders are written in curly braces: {id}, {player}, ... # -# Switch language in config.yml: language: en -# Switch command language in config.yml: command-language: de | en | both +# Switch language in config.yml: language: en # -# {cmd_X} is automatically replaced based on command-language, e.g.: -# command-language: de → /ticket erstellen -# command-language: en → /ticket create -# command-language: both → /ticket create (erstellen) +# {cmd_X} is automatically replaced based on language, e.g.: +# language: de → /ticket erstellen +# language: en → /ticket create +# language: both → /ticket create (erstellen) # ============================================================ prefix: "ᖳFF[&fTicketᖳFF] &r" @@ -176,17 +175,17 @@ blacklist: # ============================================================ stats: header: "&6Ticket Statistics" - total: "&eTotal: &a{count}" - open: "&eOpen: &a{count}" - closed: "&eClosed: &a{count} &7(historical)" - forwarded: "&eForwarded: &a{count}" + total: "&eTotal: &a{count}" + open: "&eOpen: &a{count}" + closed: "&eClosed: &a{count} &7(historical)" + forwarded: "&eForwarded: &a{count}" ratings-header: "&6Support Ratings &7(total, historical)" ratings-summary: "&a👍 Positive: &f{up} &c👎 Negative: &f{down}" ratings-percent: "&7Satisfaction: &e{percent}%" - staff-header: "&6Ratings by support staff:" + staff-header: "&6Ratings by Support Staff:" staff-table-header: "&7 Name 👍 👎 Tickets Satisfied" staff-entry: "&e {name} &a{up} &c{down} &7{total} &e{percent}" - servers-header: "&6Tickets by server:" + servers-header: "&6Tickets by Server:" server-entry: "&b {server}: &a{count}" top-header: "&6Top-5 Ticket Creators &7(historical, persistent)" top-empty: "&7No data available yet." @@ -202,7 +201,7 @@ top: header: "&6&lTop-5 Ticket Creators" empty: "&7No data available yet." entry: "{medal} &f{name} &e{count} &7{label}" - footer: "&7(Counts are kept even after tickets are deleted)" + footer: "&7(Counters persist even after tickets are deleted)" # ============================================================ # RELOAD @@ -238,28 +237,44 @@ faq: created: "&aFAQ &e#{id} &ahas been created successfully!" created-question: "&7Question: &e{question}" created-answer: "&7Answer: &f{answer}" + created-category-info: "&7Category: {category}" updated: "&aFAQ &e#{id} &ahas been updated successfully!" deleted: "&aFAQ &e#{id} &ahas been deleted." not-found: "&cFAQ &e#{id} &cwas not found." reloaded: "&aFAQs reloaded. ({count} entries)" - list-header: "&6Frequently Asked Questions &7— {count} entries" + list-header: "&6Frequently Asked Questions (FAQ) &7— {count} entries" list-empty: "&7No FAQs available yet." list-entry: "&e#{id} &f{question}" list-answer: " &7→ &f{answer}" list-admin-hint: "&7Commands: &e{cmd_faq} add &8| &e{cmd_faq} edit &8| &e{cmd_faq} delete " - unknown-sub: "&cUnknown FAQ command." + unknown-sub: "&cUnknown FAQ subcommand." hint-open: "&7Use &e{cmd_faq} &7to open the GUI." admin-commands: "&7Admin commands: &e{cmd_faq} add | edit | delete | reload | list" +# ============================================================ +# FAQ CATEGORY COMMAND (/ticket category) +# ============================================================ +faqcat: + usage: "&cUsage: /ticket category " + usage-add: "&cUsage: /ticket category add [&Color] [Description]" + usage-delete: "&cUsage: /ticket category delete " + created: "&aFAQ category &e{name} &acreated! &7(Key: &e{key}&7)" + deleted: "&aFAQ category &e{key} &ahas been deleted. &7FAQs are now uncategorized." + not-found: "&cFAQ category &e{key} &cwas not found." + already-exists: "&cA FAQ category with key &e{name} &calready exists." + list-header: "&6FAQ Categories &7({count} entries)" + list-empty: "&7No FAQ categories yet." + list-entry: "&e{key} &8→ &r{name} &8| &7{count} FAQ(s) &8| &7{desc}" + # ============================================================ # HELP MENU (/ticket without arguments) # ============================================================ help: - header: "�FF00&lTicketSystem &7– Commands" + header: "�FFFF&lTicketSystem &7– Commands" create: "&e{cmd_create} [category] &7– Create a new ticket" list: "&e{cmd_list} &7– View your tickets (GUI)" comment: "&e{cmd_comment} &7– Add a message to a ticket" - rate: "&e{cmd_rate} &7– Rate the support" + rate: "&e{cmd_rate} &7– Rate support" claim: "&e{cmd_claim} &7– Claim a ticket" close: "&e{cmd_close} [comment] &7– Close a ticket" forward: "&e{cmd_forward} &7– Forward a ticket" @@ -272,18 +287,18 @@ help: # GUI TEXTS (TicketGUI) # ============================================================ gui: - # ── Chat messages ──────────────────────────────────────── - no-archive-permission: "&cYou don't have permission to open the archive." - no-tickets: "&aYou don't have any tickets right now." + # ── Chat Messages ─────────────────────────────────────── + no-archive-permission: "&cYou don't have permission to view the archive." + no-tickets: "&aYou currently have no tickets." filter-label: "&7Filter: {filter}" ticket-removed: "&aYour ticket &e#{id} &ahas been removed from your overview." ticket-remove-error: "&cFailed to remove the ticket." - ticket-remove-claimed: "&cYou cannot delete this ticket because it is already being handled by a supporter." + ticket-remove-claimed: "&cYou cannot delete this ticket as it is already being processed by a supporter." teleport-success: "&7You have been teleported to ticket &e#{id}&7." world-not-loaded: "&cThe world of this ticket is not loaded!" teleport-disabled: "&cCross-server teleport is disabled in the config.{hint}" teleport-unknown: "&cTicket server unknown – teleport not possible." - bungee-connect: "&7Connecting to server &b{server} &7for ticket &e#{id}&7..." + bungee-connect: "&7Connecting you to server &b{server} &7for ticket &e#{id}&7..." bungee-connect-fail: "&cServer switch failed. Please connect manually." no-delete-permission: "&cYou don't have permission to permanently delete tickets." only-closed-deletable: "&cOnly closed tickets can be permanently deleted." @@ -292,18 +307,18 @@ gui: already-closed: "&cThis ticket is already closed." close-prompt-header: "&6Close ticket #{id}" close-prompt-hint: "&7Enter a comment (&e- &7for none)." - close-prompt-cancel: "&7Type &ccancel &7to abort." + close-prompt-cancel: "&7Cancel with &ccancel" close-cancelled: "&cCancelled." close-comment-echo: "&7Comment: &f{comment}" no-priority-permission: "&cYou don't have permission to change the priority." priority-closed: "&cThe priority of closed tickets cannot be changed." priority-set: "&aPriority set to {priority}&a." priority-error: "&cFailed to change the priority." - comments-header: "&6Comments on ticket #{id}" + comments-header: "&6Comments for ticket #{id}" comments-empty: "&7No comments yet." comments-entry: "&e{author} &7({time})&8: &f{message}" - # ── Inventory titles ───────────────────────────────────── + # ── Inventory Titles ───────────────────────────────────── item: title-admin: "§8§lTicket Overview" title-archive: "§8§lTicket Archive" @@ -408,9 +423,9 @@ gui: nav-filter-click: "§8Click to cycle" nav-filter-all: "§7All (no filter)" - # ── FAQ GUI Texts (New) ───────────────────────────────── + # ── FAQ GUI Texts ───────────────────────────────────────── faq: - title: "�FF00&lFrequently Asked Questions (FAQ)" + title: "�FF00&lFrequently Asked Questions (FAQ)" admin-title: "§8§lManage FAQ" action-title: "§8§lFAQ Actions" @@ -425,32 +440,84 @@ gui: delete-button: "§c§lDelete FAQ" delete-lore-1: "§7Deletes this FAQ entry." delete-lore-2: "§c§lWARNING: §cCannot be undone!" - delete-error: "§cError: FAQ #{id} could not be deleted." + delete-error: "§cError: FAQ #{id} could not be deleted." back-button: "§7§lBack" - back-lore: "§7Back to FAQ overview." + back-lore: "§7Back to FAQ overview." click-detail: "§e» Click for more details in chat" - click-edit: "§e» Click to edit / delete" + click-edit: "§e» Click to edit / delete" - nav-prev: "§7§l◄ Previous" + nav-prev: "§7§l◄ Previous" nav-prev-lore: "§7Page {page} of {total}" - nav-next: "§7§lNext ►" + nav-next: "§7§lNext ►" nav-next-lore: "§7Page {page} of {total}" - nav-page: "§8Page {page}/{total}" + nav-page: "§8Page {page}/{total}" nav-page-lore: "§7Total: {count} FAQ(s)" - chat-create-title: "§6§lCreate new FAQ" - chat-question-prompt: "§7Enter the §eQuestion §7(or §ccancel§7):" - chat-answer-prompt: "§7Now enter the §eAnswer §7(or §ccancel§7):" - chat-edit-title: "§6§lEdit FAQ #{id}" + chat-create-title: "§6§lCreate new FAQ" + chat-question-prompt: "§7Enter the §eQuestion §7(or §ccancel§7):" + chat-answer-prompt: "§7Now enter the §eAnswer §7(or §ccancel§7):" + chat-edit-title: "§6§lEdit FAQ #{id}" chat-current-question: "§7Current Question: §e{question}" - lore-id: "§7FAQ #{id}" + lore-id: "§7FAQ #{id}" lore-separator: "§8§m " - question-set: "§7Question set: §e{question}" + lore-category: "§7Category: {category}" + question-set: "§7Question set: §e{question}" internal-error: "§cInternal error while editing the FAQ." + # ── Category selection screen ──────────────────────── + cat-title: "�FF00&lFAQ – Select Category" + cat-admin-title: "§8§lFAQ – Manage Category" + + cat-lore-separator: "§8§m " + cat-lore-count: "§7Entries: §e{count}" + cat-lore-click: "§e» Click to open" + cat-lore-admin-hint: "§8(Admin: Add new FAQ to this category)" + cat-lore-shift-hint: "§8Shift+Click to manage" + + cat-back-button: "§7§l◄ Categories" + cat-back-lore: "§7Back to category selection." + + cat-add-button: "§a§l+ New Category" + cat-add-lore-1: "§7Creates a new FAQ category." + cat-add-lore-2: "§7Use: /ticket category add " + + # ── Category action GUI ────────────────────────────── + cat-action-title: "§8§lManage Category" + cat-edit-button: "§a§lEdit Category" + cat-edit-lore-1: "§7Change name, color and" + cat-edit-lore-2: "§7description of this category." + cat-delete-button: "§c§lDelete Category" + cat-delete-lore-1: "§7Deletes this category." + cat-delete-lore-2: "§7FAQs will be set to 'No category'." + + # ── Category chat flow ─────────────────────────────── + cat-chat-add-title: "§6§lCreate New Category" + cat-chat-edit-title: "§6§lEdit Category: {name}" + cat-chat-current-name: "§7Current name: §e{name}" + cat-chat-current-color: "§7Current color: {color}" + cat-chat-current-desc: "§7Current description: §f{desc}" + cat-chat-name-prompt: "§7Enter the §eName §7(or §ccancel§7):" + cat-chat-name-set: "§7Name set: §e{name}" + cat-chat-color-prompt: "§7Enter the §ecolor code§7, e.g. §a&a§7 or §c&c §7(or §ccancel§7):" + cat-chat-color-set: "§7Color set: {colored}" + cat-chat-desc-prompt: "§7Enter the §eDescription §7(§8- §7for none, or §ccancel§7):" + + cat-created: "§aCategory §e{name} §awas created!" + cat-updated: "§aCategory §e{name} §awas updated!" + cat-deleted: "§aCategory §e{name} §awas deleted." + cat-already-exists: "§cA category with key '§e{name}§c' already exists." + cat-not-found: "§cCategory '§e{name}§c' was not found." + + # ── Dynamic titles with category ───────────────────── + title-cat: "�FF00&lFAQ – {category}" + admin-title-cat: "§8§lFAQ – {category}" + title-cat-prefix: "�FF00&lFAQ" + admin-title-cat-prefix: "§8§lFAQ" + chat-category-hint: "§7Category: {category}" + # ============================================================ # JOIN LISTENER # ============================================================ @@ -468,4 +535,119 @@ update: available-console: "New version available: {new} (current: {current})" available-bar: "====================================================" available-line1: "&6[TicketSystem] &eNEW UPDATE AVAILABLE: v{version}" - available-line2: "&6[TicketSystem] &eDownload: https://www.spigotmc.org/resources/132757" \ No newline at end of file + available-line2: "&6[TicketSystem] &eDownload: https://www.spigotmc.org/resources/132757" +# ============================================================ +# WEB-PANEL TEXTS (browser user interface) +# ============================================================ +web: + # ── Navigation ──────────────────────────────────────────── + nav-dashboard: "Dashboard" + nav-tickets: "Tickets" + nav-faq: "FAQ" + nav-logout: "Sign Out" + role-admin: "Admin" + role-supporter: "Supporter" + btn-back: "Back" + btn-details: "Details" + + # ── Error pages ─────────────────────────────────────────── + error-403-title: "403 – Access Denied" + error-403-message: "This page is only accessible to administrators." + + # ── Login page ──────────────────────────────────────────── + login-title: "Login – TicketSystem Panel" + login-heading: "Welcome back" + login-sub: "Sign in with your account to continue." + login-error: "Username or password incorrect." + login-label-user: "Username" + login-label-pass: "Password" + login-btn: "Sign In" + login-footer: "TicketSystem Panel · For Supporters & Admins only" + + # ── Dashboard ───────────────────────────────────────────── + dash-title: "Dashboard" + dash-stat-total: "Total Tickets" + dash-stat-open: "Open / Active" + dash-stat-closed: "Closed" + dash-stat-thumbsup: "👍 Ratings" + dash-stat-thumbsdown: "👎 Ratings" + dash-stat-rating: "Satisfaction" + dash-section-recent: "Recent Tickets" + dash-section-top: "Top Ticket Creators" + dash-section-server: "Tickets per Server" + dash-empty: "No open tickets 🎉" + dash-col-id: "#" + dash-col-player: "Player" + dash-col-category: "Category" + dash-col-priority: "Priority" + dash-col-status: "Status" + dash-col-created: "Created" + dash-col-server: "Server" + dash-col-count: "Tickets" + + # ── Ticket list ─────────────────────────────────────────── + tickets-title: "Tickets" + tickets-total: "total" + tickets-empty: "No tickets found" + tickets-search-ph: "Search…" + tickets-col-id: "#" + tickets-col-player: "Player" + tickets-col-message: "Message" + tickets-col-cat: "Category" + tickets-col-prio: "Priority" + tickets-col-status: "Status" + tickets-col-created: "Created" + filter-all-status: "All Statuses" + filter-open: "Open" + filter-claimed: "Claimed" + filter-forwarded: "Forwarded" + filter-closed: "Closed" + filter-all-cat: "All Categories" + filter-all-prio: "All Priorities" + filter-low: "Low" + filter-normal: "Normal" + filter-high: "High" + filter-urgent: "Urgent" + + # ── Ticket detail ───────────────────────────────────────── + detail-not-found: "Ticket not found" + detail-back: "← Back" + detail-info-creator: "Creator" + detail-info-created: "Created" + detail-info-claimer: "Assigned to" + detail-info-forwarded: "Forwarded to" + detail-info-position: "Position" + detail-info-rating: "Rating" + detail-rating-pos: "👍 Positive" + detail-rating-neg: "👎 Negative" + detail-section-msg: "Message" + detail-section-closecomment: "Close Comment" + detail-section-actions: "Actions" + detail-section-comments: "Comments" + detail-btn-claim: "✅ Claim" + detail-btn-forward: "🔀 Forward" + detail-btn-close: "🔒 Close" + detail-ph-forward: "Player name…" + detail-ph-comment: "Comment (optional)" + detail-ph-newcomment: "Write a comment…" + detail-btn-send: "Send" + detail-no-comments: "No comments yet." + + # ── FAQ management ──────────────────────────────────────── + faq-title: "FAQ Management" + faq-entries-suffix: "Entries" + faq-section-cats: "Categories" + faq-no-category: "Uncategorized" + faq-btn-add: "+ Add FAQ" + faq-empty: "No FAQ entries yet." + faq-no-cat-opt: "No Category" + faq-modal-add-title: "Add FAQ" + faq-modal-edit-title: "Edit FAQ" + faq-label-question: "Question" + faq-label-answer: "Answer" + faq-label-category: "Category" + faq-ph-question: "Question…" + faq-ph-answer: "Answer…" + faq-btn-cancel: "Cancel" + faq-btn-save: "Save" + faq-btn-add-confirm: "Add" \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 1d10bd0..0ebf272 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ name: TicketSystem -version: 1.0.9 +version: 1.1.2 main: de.ticketsystem.TicketPlugin api-version: 1.20 author: M_Viper