Upload folder via GUI - src

This commit is contained in:
Git Manager GUI
2026-04-15 19:11:02 +02:00
parent bafddee288
commit d66871234c
20 changed files with 3854 additions and 451 deletions

View File

@@ -14,6 +14,8 @@ import de.ticketsystem.manager.LanguageManager;
import de.ticketsystem.manager.TicketManager; import de.ticketsystem.manager.TicketManager;
import de.ticketsystem.model.Ticket; import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketPriority; import de.ticketsystem.model.TicketPriority;
import de.ticketsystem.web.SessionManager;
import de.ticketsystem.web.WebServer;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
@@ -41,6 +43,8 @@ public class TicketPlugin extends JavaPlugin {
private DiscordWebhook discordWebhook; private DiscordWebhook discordWebhook;
private BungeeMessenger bungeeMessenger; private BungeeMessenger bungeeMessenger;
private TicketCache ticketCache; private TicketCache ticketCache;
private SessionManager sessionManager;
private WebServer webServer;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -91,7 +95,7 @@ public class TicketPlugin extends JavaPlugin {
// Versionsprüfung der config.yml // Versionsprüfung der config.yml
String configVersion = getConfig().getString("version", ""); String configVersion = getConfig().getString("version", "");
String expectedVersion = "2.2"; String expectedVersion = "2.4";
if (!expectedVersion.equals(configVersion)) { if (!expectedVersion.equals(configVersion)) {
getLogger().warning("[WARNUNG] config.yml-Version (" + configVersion getLogger().warning("[WARNUNG] config.yml-Version (" + configVersion
+ ") stimmt nicht mit der erwarteten Version (" + expectedVersion + ") überein!"); + ") stimmt nicht mit der erwarteten Version (" + expectedVersion + ") überein!");
@@ -149,6 +153,13 @@ public class TicketPlugin extends JavaPlugin {
}, ticks, ticks); }, 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!"); getLogger().info("TicketSystem v" + getDescription().getVersion() + " erfolgreich gestartet!");
} }
@@ -157,6 +168,7 @@ public class TicketPlugin extends JavaPlugin {
getServer().getMessenger().unregisterOutgoingPluginChannel(this); getServer().getMessenger().unregisterOutgoingPluginChannel(this);
getServer().getMessenger().unregisterIncomingPluginChannel(this); getServer().getMessenger().unregisterIncomingPluginChannel(this);
if (webServer != null) webServer.stop();
if (ticketCache != null) ticketCache.clear(); if (ticketCache != null) ticketCache.clear();
if (databaseManager != null) databaseManager.disconnect(); if (databaseManager != null) databaseManager.disconnect();
getLogger().info("TicketSystem wurde deaktiviert."); getLogger().info("TicketSystem wurde deaktiviert.");
@@ -286,4 +298,6 @@ public class TicketPlugin extends JavaPlugin {
public boolean isDebug() { return debug; } public boolean isDebug() { return debug; }
public String getServerName() { return serverName; } public String getServerName() { return serverName; }
public boolean isBungeeCordEnabled() { return getConfig().getBoolean("bungeecord", false); } public boolean isBungeeCordEnabled() { return getConfig().getBoolean("bungeecord", false); }
public SessionManager getSessionManager() { return sessionManager; }
public WebServer getWebServer() { return webServer; }
} }

View File

@@ -2,6 +2,7 @@ package de.ticketsystem.commands;
import de.ticketsystem.TicketPlugin; import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.ConfigCategory; import de.ticketsystem.model.ConfigCategory;
import de.ticketsystem.model.FaqCategory;
import de.ticketsystem.model.FaqEntry; import de.ticketsystem.model.FaqEntry;
import de.ticketsystem.model.Ticket; import de.ticketsystem.model.Ticket;
import de.ticketsystem.model.TicketComment; import de.ticketsystem.model.TicketComment;
@@ -56,6 +57,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
case "bewerten" -> "rate"; case "bewerten" -> "rate";
case "priorität", "prioritaet" -> "setpriority"; case "priorität", "prioritaet" -> "setpriority";
case "hilfe" -> "help"; case "hilfe" -> "help";
case "kategorie" -> "category";
// Englisch + alles andere → unverändert // Englisch + alles andere → unverändert
default -> input.toLowerCase(); default -> input.toLowerCase();
}; };
@@ -93,6 +95,7 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
case "rate" -> handleRate(player, args); case "rate" -> handleRate(player, args);
case "setpriority" -> handleSetPriority(player, args); case "setpriority" -> handleSetPriority(player, args);
case "faq" -> handleFaq(player, args); case "faq" -> handleFaq(player, args);
case "category" -> handleFaqCategory(player, args);
default -> plugin.getTicketManager().sendHelpMessage(player); default -> plugin.getTicketManager().sendHelpMessage(player);
} }
return true; 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"));
player.sendMessage(plugin.lang().get("faq.usage-add-example")); return; 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); String[] parts = full.split("\\s*\\|\\s*", 2);
if (parts.length < 2 || parts[0].isBlank() || parts[1].isBlank()) { 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-missing"));
player.sendMessage(plugin.lang().get("faq.separator-example")); return; 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", "{id}", String.valueOf(created.getId())));
player.sendMessage(plugin.lang().format("faq.created-question", "{question}", created.getQuestion())); player.sendMessage(plugin.lang().format("faq.created-question", "{question}", created.getQuestion()));
player.sendMessage(plugin.lang().format("faq.created-answer", "{answer}", created.getAnswer())); 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" -> { case "edit", "bearbeiten" -> {
@@ -837,6 +863,90 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
} }
} }
// ── /ticket kategorie (category) ─────────────────────────────────────
//
// /ticket kategorie add <Name> [Farbe] [Beschreibung]
// Farbe optional, z.B. &b (Standard: &7)
// Beschreibung optional, alles nach der Farbe
//
// /ticket kategorie delete <Schlüssel>
// /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 <Name> [&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 ───────────────────────────────────────────────────── // ── Hilfsmethoden ─────────────────────────────────────────────────────
private Ticket getCachedOrFetch(int ticketId) { private Ticket getCachedOrFetch(int ticketId) {
@@ -858,6 +968,14 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
}; };
} }
private List<String> getFaqCategoryKeysForTab(String input) {
String lower = input == null ? "" : input.toLowerCase();
List<String> result = new ArrayList<>();
for (FaqCategory cat : plugin.getFaqManager().getAllCategories())
if (cat.getKey().startsWith(lower)) result.add(cat.getKey());
return result;
}
private List<String> getPriorityInputsForTab(String input) { private List<String> getPriorityInputsForTab(String input) {
List<String> options = new ArrayList<>(); List<String> options = new ArrayList<>();
String lowerInput = input == null ? "" : input.toLowerCase(); String lowerInput = input == null ? "" : input.toLowerCase();
@@ -910,6 +1028,11 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
if (useDe) subs.addAll(List.of("faq")); 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 (useEn) subs.add("rate");
if (useDe) subs.add("bewerten"); if (useDe) subs.add("bewerten");
@@ -928,6 +1051,13 @@ public class TicketCommand implements CommandExecutor, TabCompleter {
for (String s : faqSubs) for (String s : faqSubs)
if (s.startsWith(args[1].toLowerCase())) completions.add(s); 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 <TAB> → FAQ-Kategorie-Schlüssel vorschlagen
completions.addAll(getFaqCategoryKeysForTab(args[2]));
} else if (args.length == 3 && normalize(args[0]).equals("faq") } else if (args.length == 3 && normalize(args[0]).equals("faq")
&& (args[1].equalsIgnoreCase("edit") || args[1].equalsIgnoreCase("delete") && (args[1].equalsIgnoreCase("edit") || args[1].equalsIgnoreCase("delete")
|| args[1].equalsIgnoreCase("bearbeiten") || args[1].equalsIgnoreCase("löschen")) || 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()) for (FaqEntry e : plugin.getFaqManager().getAll())
completions.add(String.valueOf(e.getId())); 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 <TAB>
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 <TAB> → vorhandene Schlüssel
completions.addAll(getFaqCategoryKeysForTab(args[2]));
} else if (args.length == 2 && normalize(args[0]).equals("create")) { } else if (args.length == 2 && normalize(args[0]).equals("create")) {
boolean categoriesOn = plugin.getConfig().getBoolean("categories-enabled", true); boolean categoriesOn = plugin.getConfig().getBoolean("categories-enabled", true);
boolean prioritiesOn = plugin.getConfig().getBoolean("priorities-enabled", true); boolean prioritiesOn = plugin.getConfig().getBoolean("priorities-enabled", true);

View File

@@ -1,6 +1,7 @@
package de.ticketsystem.gui; package de.ticketsystem.gui;
import de.ticketsystem.TicketPlugin; import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.FaqCategory;
import de.ticketsystem.model.FaqEntry; import de.ticketsystem.model.FaqEntry;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Material; import org.bukkit.Material;
@@ -23,204 +24,287 @@ import java.util.*;
/** /**
* FAQ GUI für Spieler (Lesemodus) und Admins (Verwaltungsmodus). * 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 { public class FaqGUI implements Listener {
// ── Konfigurierbare Felder ──────────────────────────────────────────────── // ── Konfigurierbare Felder ────────────────────────────────────────────────
private int faqRows; private int faqRows;
private int faqNavPrev, faqNavNext, faqNavAdd, faqNavPage; private int faqNavPrev, faqNavNext, faqNavAdd, faqNavPage;
private Material headMaterial = Material.PLAYER_HEAD; private Material headMaterial = Material.PLAYER_HEAD;
private String headTexture = "http://textures.minecraft.net/texture/da2fde34d34c8588e58bfd790ce18025f7843399dee2ab4cedc2c0b463fd1e"; private String headTexture = DEFAULT_SKIN_URL;
private Material catHeadMaterial = Material.PLAYER_HEAD;
private String catHeadTexture = DEFAULT_CAT_SKIN_URL;
private List<Integer> contentSlots = new ArrayList<>(); private List<Integer> contentSlots = new ArrayList<>();
private Material matNavPrev, matNavNext, matNavPage, matNavAdd;
// ── System Felder ────────────────────────────────────────────────────────
private static final String DEFAULT_SKIN_URL = private static final String DEFAULT_SKIN_URL =
"http://textures.minecraft.net/texture/da2fde34d34c8588e58bfd790ce18025f7843399dee2ab4cedc2c0b463fd1e"; "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 TicketPlugin plugin;
// ── State-Maps ───────────────────────────────────────────────────────────
private final Map<UUID, Map<Integer, FaqEntry>> slotMap = new HashMap<>(); private final Map<UUID, Map<Integer, FaqEntry>> slotMap = new HashMap<>();
private final Map<UUID, Map<Integer, FaqCategory>> catSlotMap = new HashMap<>();
private final Map<UUID, Integer> faqPage = new HashMap<>(); private final Map<UUID, Integer> faqPage = new HashMap<>();
private final Set<UUID> adminView = new HashSet<>(); private final Set<UUID> adminView = new HashSet<>();
private final Map<UUID, FaqEntry> actionEntry = new HashMap<>(); private final Map<UUID, FaqEntry> actionEntry = new HashMap<>();
private final Map<UUID, String> activeCategory = new HashMap<>();
/** Kategorie die gerade im Aktions-GUI bearbeitet wird */
private final Map<UUID, FaqCategory> catAction = new HashMap<>();
// ── Chat-Flow-Maps ───────────────────────────────────────────────────────
private final Map<UUID, String> awaitingQuestion = new HashMap<>(); private final Map<UUID, String> awaitingQuestion = new HashMap<>();
private final Map<UUID, String> awaitingAnswer = new HashMap<>(); private final Map<UUID, String> awaitingAnswer = new HashMap<>();
private final Map<UUID, String> 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:<name>", "cat_desc:<name>:<color>",
* "cat_edit_name:<key>", "cat_edit_color:<key>:<name>", "cat_edit_desc:<key>:<name>:<color>"
*/
private final Map<UUID, String> awaitingCatInput = new HashMap<>();
public FaqGUI(TicketPlugin plugin) { public FaqGUI(TicketPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
reloadConfig(); reloadConfig();
} }
/** // ═══════════════════════════════════════════════════════════════════════
* Berechnet die Item-Slots: // CONFIG
* 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
*/
private List<Integer> buildPatternSlots(int rows) { private List<Integer> buildPatternSlots(int rows) {
List<Integer> slots = new ArrayList<>(); List<Integer> slots = new ArrayList<>();
int contentRows = rows - 1; // Letzte Reihe = Navigation int contentRows = rows - 1;
for (int row = 0; row < contentRows; row++) { for (int row = 0; row < contentRows; row++) {
int base = row * 9; int base = row * 9;
if (row % 2 == 0) { if (row % 2 == 0) {
// Gerade Reihen (0,2,4,...): Items in geraden Spalten slots.add(base); slots.add(base + 2); slots.add(base + 4);
slots.add(base + 0); slots.add(base + 6); slots.add(base + 8);
slots.add(base + 2);
slots.add(base + 4);
slots.add(base + 6);
slots.add(base + 8);
} else { } else {
// Ungerade Reihen (1,3,5,...): Items in ungeraden Spalten slots.add(base + 1); slots.add(base + 3);
slots.add(base + 1); slots.add(base + 5); slots.add(base + 7);
slots.add(base + 3);
slots.add(base + 5);
slots.add(base + 7);
} }
} }
return slots; 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) { private void applyNavDefaults(int rows) {
int navBase = (rows - 1) * 9; // Erster Slot der letzten Reihe int navBase = (rows - 1) * 9;
faqNavPrev = navBase + 1; // 2. Slot (ungerade) Blättern zurück faqNavPrev = navBase + 1;
faqNavAdd = navBase + 3; // 4. Slot (ungerade) Hinzufügen faqNavAdd = navBase + 3;
faqNavPage = navBase + 5; // 6. Slot (ungerade) Seitenanzeige faqNavPage = navBase + 5;
faqNavNext = navBase + 7; // 8. Slot (ungerade) Blättern vor faqNavNext = navBase + 7;
} }
/**
* Lädt die Konfiguration sicher mit Fallback-Werten.
*/
public void reloadConfig() { public void reloadConfig() {
// ── 1. STANDARDWERTE SETZEN (Sicherheit gegen leere Config) ─────────
faqRows = 6; faqRows = 6;
headMaterial = Material.PLAYER_HEAD; headMaterial = Material.PLAYER_HEAD;
headTexture = DEFAULT_SKIN_URL; headTexture = DEFAULT_SKIN_URL;
catHeadMaterial = Material.PLAYER_HEAD;
catHeadTexture = DEFAULT_CAT_SKIN_URL;
matNavPrev = Material.ARROW; matNavPrev = Material.ARROW;
matNavNext = Material.ARROW; matNavNext = Material.ARROW;
matNavPage = Material.PAPER; matNavPage = Material.PAPER;
matNavAdd = Material.LIME_WOOL; matNavAdd = Material.LIME_WOOL;
// Standard Content-Slots nach Muster: Glasscheiben in Spalte 0, 4, 8
contentSlots = buildPatternSlots(faqRows); contentSlots = buildPatternSlots(faqRows);
applyNavDefaults(faqRows); applyNavDefaults(faqRows);
// ── 2. VERSUCHEN AUS CONFIG ZU LADEN ───────────────────────────────── ConfigurationSection gs = plugin.getConfig().getConfigurationSection("gui-settings");
ConfigurationSection guiSettings = plugin.getConfig().getConfigurationSection("gui-settings"); if (gs == null) return;
if (guiSettings != null) { ConfigurationSection fc = gs.getConfigurationSection("faq");
ConfigurationSection faqConf = guiSettings.getConfigurationSection("faq"); if (fc != null) {
if (faqConf != null) { faqRows = Math.max(4, Math.min(6, fc.getInt("rows", 6)));
// Rows laden
faqRows = faqConf.getInt("rows", 6);
if (faqRows < 4) faqRows = 4; // Minimum 4 Reihen
if (faqRows > 6) faqRows = 6;
// Nav-Defaults für die gewählte Zeilenanzahl setzen
applyNavDefaults(faqRows); applyNavDefaults(faqRows);
if (fc.contains("content-slots") && !fc.getIntegerList("content-slots").isEmpty())
// Content Slots laden nur überschreiben wenn explizit in config gesetzt contentSlots = fc.getIntegerList("content-slots");
if (faqConf.contains("content-slots") && !faqConf.getIntegerList("content-slots").isEmpty()) { else
contentSlots = faqConf.getIntegerList("content-slots");
} else {
// Muster für die konfigurierte Zeilenanzahl neu berechnen
contentSlots = buildPatternSlots(faqRows); contentSlots = buildPatternSlots(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);
ConfigurationSection hc = fc.getConfigurationSection("head-item");
if (hc != null) {
headMaterial = getMat(hc, "material", Material.PLAYER_HEAD);
headTexture = hc.getString("texture", DEFAULT_SKIN_URL);
} }
ConfigurationSection cc = fc.getConfigurationSection("category-head-item");
// Navigation Slots laden (Config überschreibt dynamische Defaults) if (cc != null) {
faqNavPrev = getSlot(faqConf, "nav.prev", faqNavPrev, faqRows); catHeadMaterial = getMat(cc, "material", Material.PLAYER_HEAD);
faqNavNext = getSlot(faqConf, "nav.next", faqNavNext, faqRows); String tex = cc.getString("texture", "");
faqNavPage = getSlot(faqConf, "nav.page", faqNavPage, faqRows); catHeadTexture = tex.isBlank() ? DEFAULT_CAT_SKIN_URL : tex;
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);
} }
} }
// Materialien laden (Global) ConfigurationSection is = gs.getConfigurationSection("items");
ConfigurationSection itemsSettings = guiSettings.getConfigurationSection("items"); if (is != null) {
if (itemsSettings != null) { matNavPrev = getMat(is, "nav-prev", Material.ARROW);
matNavPrev = getMaterial(itemsSettings, "nav-prev", Material.ARROW); matNavNext = getMat(is, "nav-next", Material.ARROW);
matNavNext = getMaterial(itemsSettings, "nav-next", Material.ARROW); matNavPage = getMat(is, "nav-page", Material.PAPER);
matNavPage = getMaterial(itemsSettings, "nav-page", Material.PAPER); matNavAdd = getMat(is, "nav-add", Material.LIME_WOOL);
matNavAdd = getMaterial(itemsSettings, "nav-add", Material.LIME_WOOL);
}
} }
} }
private int getSlot(ConfigurationSection section, String path, int def, int rows) { private int getSlot(ConfigurationSection s, String p, int def, int rows) {
int val = section.getInt(path, def); int val = s.getInt(p, def), max = rows * 9;
int max = rows * 9;
if (val >= 0 && val < max) return val; if (val >= 0 && val < max) return val;
// Slot liegt außerhalb des Inventars → Spalte beibehalten, letzte Reihe verwenden return (rows - 1) * 9 + (val >= 0 ? val : def) % 9;
int col = (val >= 0 ? val : def) % 9;
return (rows - 1) * 9 + col;
} }
private Material getMaterial(ConfigurationSection section, String path, Material def) { private Material getMat(ConfigurationSection s, String p, Material def) {
try { try { return Material.valueOf(s.getString(p, def.name())); }
return Material.valueOf(section.getString(path, def.name())); catch (IllegalArgumentException e) { return def; }
} catch (IllegalArgumentException e) {
return def;
}
} }
// ── Hilfsmethode für Sprachzugriff ───────────────────────────────────────── // ── Lang ──────────────────────────────────────────────────────────────
private String f(String key) { private String f(String key) { return plugin.lang().get("gui.faq." + key); }
return plugin.lang().get("gui.faq." + key); private String f(String key, String... r) { return plugin.lang().format("gui.faq." + key, r); }
}
private String f(String key, String... replacements) {
return plugin.lang().format("gui.faq." + key, replacements);
}
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// PUBLIC OPEN-METHODEN // EINSTIEGSPUNKT
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
public void openFaqGUI(Player player) { 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) { // ═══════════════════════════════════════════════════════════════════════
// KATEGORIE-AUSWAHL-SCREEN
// ═══════════════════════════════════════════════════════════════════════
public void openCategoryScreen(Player player) {
boolean isAdmin = player.hasPermission("ticket.admin"); boolean isAdmin = player.hasPermission("ticket.admin");
String title = isAdmin ? f("admin-title") : f("title"); String title = isAdmin ? f("cat-admin-title") : f("cat-title");
List<FaqEntry> all = plugin.getFaqManager().getAll(); List<FaqCategory> 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<Integer, FaqCategory> csm = new HashMap<>();
List<Integer> 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<Integer> buildCategorySlots(int rows, int count) {
List<Integer> 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<String> 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<String> buildCategoryLore(FaqCategory cat, int count, boolean isAdmin) {
List<String> 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<FaqEntry> all = (categoryKey != null)
? plugin.getFaqManager().getByCategory(categoryKey)
: plugin.getFaqManager().getAll();
// Sicherheit gegen leere Content-Slots Liste (Division by Zero)
int pageSize = contentSlots.isEmpty() ? 45 : contentSlots.size(); int pageSize = contentSlots.isEmpty() ? 45 : contentSlots.size();
int totalPages = Math.max(1, (int) Math.ceil((double) all.size() / pageSize)); int totalPages = Math.max(1, (int) Math.ceil((double) all.size() / pageSize));
page = Math.max(0, Math.min(page, totalPages - 1)); page = Math.max(0, Math.min(page, totalPages - 1));
faqPage.put(player.getUniqueId(), page); faqPage.put(player.getUniqueId(), page);
@@ -228,31 +312,20 @@ public class FaqGUI implements Listener {
int invSize = faqRows * 9; int invSize = faqRows * 9;
Inventory inv = Bukkit.createInventory(null, invSize, title); Inventory inv = Bukkit.createInventory(null, invSize, title);
Map<Integer, FaqEntry> sm = new HashMap<>(); Map<Integer, FaqEntry> 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++) { for (int i = 0; i < pageSize && (start + i) < all.size(); i++) {
FaqEntry entry = all.get(start + i); FaqEntry entry = all.get(start + i);
int slot = contentSlots.isEmpty() ? i : contentSlots.get(i); int slot = contentSlots.isEmpty() ? i : contentSlots.get(i);
if (slot < invSize) { inv.setItem(slot, buildFaqItem(entry, isAdmin)); sm.put(slot, entry); itemCount++; }
if (slot < invSize) {
inv.setItem(slot, buildFaqItem(entry, isAdmin));
sm.put(slot, entry);
itemsOnCurrentPage++;
}
} }
slotMap.put(player.getUniqueId(), sm); slotMap.put(player.getUniqueId(), sm);
if (isAdmin) adminView.add(player.getUniqueId()); if (isAdmin) adminView.add(player.getUniqueId());
else adminView.remove(player.getUniqueId()); else adminView.remove(player.getUniqueId());
// Kein Glas im Content-Bereich nur Footer (fillNavBar) hat Glasscheiben fillNavBar(inv, page, totalPages, isAdmin, all.isEmpty(), itemCount,
plugin.getFaqManager().hasCategoriesEnabled());
// Übergeben der korrekten Anzahl an die Navigationsleiste
fillNavBar(inv, page, totalPages, isAdmin, all.isEmpty(), itemsOnCurrentPage);
player.openInventory(inv); player.openInventory(inv);
} }
@@ -265,45 +338,81 @@ public class FaqGUI implements Listener {
if (!(event.getWhoClicked() instanceof Player player)) return; if (!(event.getWhoClicked() instanceof Player player)) return;
String title = event.getView().getTitle(); String title = event.getView().getTitle();
String playerTitle = f("title"); boolean isCatScreen = title.equals(f("cat-title")) || title.equals(f("cat-admin-title"));
String adminTitle = f("admin-title"); boolean isCatActionGui = title.equals(f("cat-action-title"));
String actionTitle = f("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) if (!isCatScreen && !isCatActionGui && !isFaqList && !isActionGui) return;
&& !title.equals(actionTitle)) return;
event.setCancelled(true); event.setCancelled(true);
int slot = event.getRawSlot(); int slot = event.getRawSlot();
if (slot < 0) return; 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()); FaqEntry entry = actionEntry.get(player.getUniqueId());
if (entry == null) return; if (entry == null) return;
switch (slot) { switch (slot) {
case 10 -> startEditFlow(player, entry); case 10 -> startEditFlow(player, entry);
case 12 -> deleteFaq(player, entry); case 12 -> deleteFaq(player, entry);
case 16 -> openFaqGUI(player); case 16 -> reopenAfterAction(player);
} }
return; return;
} }
// ── Kategorie-Screen ─────────────────────────────────────────────
if (isCatScreen) {
Map<Integer, FaqCategory> 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()); 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 (plugin.getFaqManager().hasCategoriesEnabled() && slot == getBackButtonSlot()) {
if (slot == faqNavNext) { openFaqGUI(player, curPage + 1); return; } openCategoryScreen(player);
if (slot == faqNavAdd && isAdmin) { startAddFlow(player); return; } 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)) { if (contentSlots.contains(slot)) {
Map<Integer, FaqEntry> sm = slotMap.get(player.getUniqueId()); Map<Integer, FaqEntry> sm = slotMap.get(player.getUniqueId());
if (sm == null) return; if (sm == null) return;
FaqEntry entry = sm.get(slot); FaqEntry entry = sm.get(slot);
if (entry == null) return; if (entry == null) return;
if (isAdmin) openActionGUI(player, entry);
if (isAdmin) { else {
openActionGUI(player, entry);
} else {
player.closeInventory(); player.closeInventory();
player.sendMessage(plugin.lang().get("general.separator")); player.sendMessage(plugin.lang().get("general.separator"));
player.sendMessage(plugin.lang().format("faq.list-entry", player.sendMessage(plugin.lang().format("faq.list-entry",
@@ -314,37 +423,52 @@ 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) { private void openActionGUI(Player player, FaqEntry entry) {
actionEntry.put(player.getUniqueId(), entry); actionEntry.put(player.getUniqueId(), entry);
Inventory inv = Bukkit.createInventory(null, 27, f("action-title")); Inventory inv = Bukkit.createInventory(null, 27, f("action-title"));
inv.setItem(4, buildFaqItem(entry, false)); inv.setItem(4, buildFaqItem(entry, false));
inv.setItem(10, buildItem(Material.WRITABLE_BOOK, f("edit-button"), inv.setItem(10, buildItem(Material.WRITABLE_BOOK, f("edit-button"), List.of(f("edit-lore-1"), f("edit-lore-2"))));
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(12, buildItem(Material.BARRIER, f("delete-button"), inv.setItem(16, buildItem(Material.ARROW, f("back-button"), List.of(f("back-lore"))));
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); fillGlass(inv);
player.openInventory(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(); player.closeInventory();
addFlowCategory.put(player.getUniqueId(), categoryKey != null ? categoryKey : "__none__");
awaitingQuestion.put(player.getUniqueId(), "new"); awaitingQuestion.put(player.getUniqueId(), "new");
player.sendMessage(plugin.lang().get("general.separator")); player.sendMessage(plugin.lang().get("general.separator"));
player.sendMessage(f("chat-create-title")); 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(f("chat-question-prompt"));
player.sendMessage(plugin.lang().get("general.separator")); player.sendMessage(plugin.lang().get("general.separator"));
} }
// ─────────────────────────── Chat-Flow: Bearbeiten ─────────────────────
private void startEditFlow(Player player, FaqEntry entry) { private void startEditFlow(Player player, FaqEntry entry) {
player.closeInventory(); player.closeInventory();
awaitingQuestion.put(player.getUniqueId(), "edit:" + entry.getId()); awaitingQuestion.put(player.getUniqueId(), "edit:" + entry.getId());
@@ -355,17 +479,40 @@ public class FaqGUI implements Listener {
player.sendMessage(plugin.lang().get("general.separator")); player.sendMessage(plugin.lang().get("general.separator"));
} }
// ─────────────────────────── Löschen ───────────────────────────────────
private void deleteFaq(Player player, FaqEntry entry) { private void deleteFaq(Player player, FaqEntry entry) {
player.closeInventory(); player.closeInventory();
boolean success = plugin.getFaqManager().delete(entry.getId()); boolean ok = plugin.getFaqManager().delete(entry.getId());
if (success) { player.sendMessage(ok
player.sendMessage(plugin.lang().format("faq.deleted", "{id}", String.valueOf(entry.getId()))); ? plugin.lang().format("faq.deleted", "{id}", String.valueOf(entry.getId()))
} else { : f("delete-error", "{id}", String.valueOf(entry.getId())));
player.sendMessage(f("delete-error", "{id}", String.valueOf(entry.getId()))); reopenAfterAction(player);
} }
openFaqGUI(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(); Player player = event.getPlayer();
UUID uuid = player.getUniqueId(); 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)) { if (awaitingQuestion.containsKey(uuid)) {
event.setCancelled(true); event.setCancelled(true);
String state = awaitingQuestion.remove(uuid); String state = awaitingQuestion.remove(uuid);
String input = event.getMessage().trim(); String input = event.getMessage().trim();
if (input.equalsIgnoreCase("cancel")) { if (input.equalsIgnoreCase("cancel")) {
addFlowCategory.remove(uuid);
Bukkit.getScheduler().runTask(plugin, () -> { Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(plugin.lang().get("gui.close-cancelled")); player.sendMessage(plugin.lang().get("gui.close-cancelled"));
openFaqGUI(player); openFaqGUI(player);
@@ -392,21 +605,19 @@ public class FaqGUI implements Listener {
} }
awaitingAnswer.put(uuid, state + "\u0000" + input); awaitingAnswer.put(uuid, state + "\u0000" + input);
Bukkit.getScheduler().runTask(plugin, () -> player.sendMessage(f("question-set", "{question}", input)));
Bukkit.getScheduler().runTask(plugin, () -> { Bukkit.getScheduler().runTask(plugin, () -> player.sendMessage(f("chat-answer-prompt")));
player.sendMessage(f("question-set", "{question}", input));
player.sendMessage(f("chat-answer-prompt"));
});
return; return;
} }
// ── Schritt 2: Warte auf Antwort ─────────────────────────────────── // ── FAQ-Antwort-Flow ─────────────────────────────────────────────
if (awaitingAnswer.containsKey(uuid)) { if (awaitingAnswer.containsKey(uuid)) {
event.setCancelled(true); event.setCancelled(true);
String stateAndQuestion = awaitingAnswer.remove(uuid); String stateAndQuestion = awaitingAnswer.remove(uuid);
String input = event.getMessage().trim(); String input = event.getMessage().trim();
if (input.equalsIgnoreCase("cancel")) { if (input.equalsIgnoreCase("cancel")) {
addFlowCategory.remove(uuid);
Bukkit.getScheduler().runTask(plugin, () -> { Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(plugin.lang().get("gui.close-cancelled")); player.sendMessage(plugin.lang().get("gui.close-cancelled"));
openFaqGUI(player); openFaqGUI(player);
@@ -417,25 +628,22 @@ public class FaqGUI implements Listener {
int sep = stateAndQuestion.indexOf("\u0000"); int sep = stateAndQuestion.indexOf("\u0000");
String state = stateAndQuestion.substring(0, sep); String state = stateAndQuestion.substring(0, sep);
String question = stateAndQuestion.substring(sep + 1); String question = stateAndQuestion.substring(sep + 1);
String catKey = addFlowCategory.remove(uuid);
Bukkit.getScheduler().runTask(plugin, () -> { Bukkit.getScheduler().runTask(plugin, () -> {
if (state.equals("new")) { 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()))); player.sendMessage(plugin.lang().format("faq.created", "{id}", String.valueOf(created.getId())));
} else { } else {
int id; int id;
try { try { id = Integer.parseInt(state.substring(5)); }
id = Integer.parseInt(state.substring(5)); catch (NumberFormatException e) { player.sendMessage(f("internal-error")); openFaqGUI(player); return; }
} catch (NumberFormatException e) {
player.sendMessage(f("internal-error"));
openFaqGUI(player);
return;
}
boolean ok = plugin.getFaqManager().edit(id, question, input); boolean ok = plugin.getFaqManager().edit(id, question, input);
if (ok) player.sendMessage(plugin.lang().format("faq.updated", "{id}", String.valueOf(id))); player.sendMessage(ok
else player.sendMessage(plugin.lang().format("faq.not-found", "{id}", String.valueOf(id))); ? 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) { private ItemStack buildFaqItem(FaqEntry entry, boolean adminHint) {
ItemStack item; String name = "§e§l" + entry.getQuestion();
List<String> lore = buildFaqLore(entry, adminHint);
if (headMaterial == Material.PLAYER_HEAD) { if (headMaterial == Material.PLAYER_HEAD)
try { return buildSkull("FAQ_" + entry.getId(), headTexture, name, lore);
item = new ItemStack(Material.PLAYER_HEAD); ItemStack item = new ItemStack(headMaterial);
SkullMeta meta = (SkullMeta) item.getItemMeta(); applyMeta(item, name, lore);
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);
}
return item; return item;
} }
@@ -492,87 +666,105 @@ public class FaqGUI implements Listener {
List<String> lore = new ArrayList<>(); List<String> lore = new ArrayList<>();
lore.add(f("lore-separator")); lore.add(f("lore-separator"));
lore.add(f("lore-id", "{id}", String.valueOf(entry.getId()))); 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")); lore.add(f("lore-separator"));
String answer = entry.getAnswer(); String answer = entry.getAnswer();
int chunkSize = 40; int chunkSize = 40;
for (int i = 0; i < answer.length(); i += chunkSize) { for (int i = 0; i < answer.length(); i += chunkSize) {
int end = Math.min(i + chunkSize, answer.length()); int end = Math.min(i + chunkSize, answer.length());
if (end < answer.length() && answer.charAt(end) != ' ') { if (end < answer.length() && answer.charAt(end) != ' ') {
int lastSpace = answer.lastIndexOf(' ', end); int ls = answer.lastIndexOf(' ', end);
if (lastSpace > i) end = lastSpace; if (ls > i) end = ls;
} }
lore.add("§f" + answer.substring(i, end).trim()); lore.add("§f" + answer.substring(i, end).trim());
i = end - chunkSize; i = end - chunkSize;
} }
lore.add(f("lore-separator")); lore.add(f("lore-separator"));
if (adminHint) lore.add(f("click-edit")); lore.add(adminHint ? f("click-edit") : f("click-detail"));
else lore.add(f("click-detail"));
return lore; return lore;
} }
private ItemStack buildItem(Material material, String displayName, List<String> lore) { private ItemStack buildSkull(String skinId, String textureUrl, String displayName, List<String> lore) {
ItemStack item = new ItemStack(material); try {
ItemMeta meta = item.getItemMeta(); ItemStack item = new ItemStack(Material.PLAYER_HEAD);
if (meta == null) return item; 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.setDisplayName(displayName);
meta.setLore(lore); meta.setLore(lore);
item.setItemMeta(meta); item.setItemMeta(meta);
return item; 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<String> lore) {
ItemStack item = new ItemStack(Material.BOOK);
applyMeta(item, name, lore);
return item;
}
private ItemStack buildItem(Material mat, String name, List<String> lore) {
ItemStack item = new ItemStack(mat);
applyMeta(item, name, lore);
return item;
}
private void applyMeta(ItemStack item, String name, List<String> lore) {
ItemMeta meta = item.getItemMeta();
if (meta == null) return;
meta.setDisplayName(name);
meta.setLore(lore);
item.setItemMeta(meta);
} }
// ─────────────────────────── Navigationsleiste ───────────────────────── // ─────────────────────────── 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(); ItemStack glass = makeGlass();
// Untere Reihe füllen (Nav-Bar)
for (int i = inv.getSize() - 9; i < inv.getSize(); i++) inv.setItem(i, glass); for (int i = inv.getSize() - 9; i < inv.getSize(); i++) inv.setItem(i, glass);
if (page > 0) { if (page > 0)
inv.setItem(faqNavPrev, buildActionItem(matNavPrev, inv.setItem(faqNavPrev, buildItem(matNavPrev, f("nav-prev"),
f("nav-prev"), List.of(f("nav-prev-lore", "{page}", String.valueOf(page),
List.of(f("nav-prev-lore", "{page}", String.valueOf(page), "{total}", String.valueOf(totalPages))))); "{total}", String.valueOf(totalPages)))));
} if (page < totalPages - 1)
if (page < totalPages - 1) { inv.setItem(faqNavNext, buildItem(matNavNext, f("nav-next"),
inv.setItem(faqNavNext, buildActionItem(matNavNext, List.of(f("nav-next-lore", "{page}", String.valueOf(page + 2),
f("nav-next"), "{total}", String.valueOf(totalPages)))));
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 inv.setItem(faqNavPage, buildItem(matNavPage,
int displayCount = isEmpty ? 0 : itemCount; f("nav-page", "{page}", String.valueOf(page + 1), "{total}", String.valueOf(totalPages)),
List.of(f("nav-page-lore", "{count}", String.valueOf(isEmpty ? 0 : itemCount)))));
inv.setItem(faqNavPage, buildActionItem(matNavPage, f("nav-page", "{page}", String.valueOf(page + 1), "{total}", String.valueOf(totalPages)), if (isAdmin)
List.of(f("nav-page-lore", "{count}", String.valueOf(displayCount))))); inv.setItem(faqNavAdd, buildItem(matNavAdd, f("add-button"),
if (isAdmin) {
inv.setItem(faqNavAdd, buildActionItem(matNavAdd, f("add-button"),
List.of(f("add-lore-1"), f("add-lore-2")))); 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) { private void fillGlass(Inventory inv) {
ItemStack glass = makeGlass(); ItemStack glass = makeGlass();
for (int i = 0; i < inv.getSize(); i++) { for (int i = 0; i < inv.getSize(); i++) if (inv.getItem(i) == null) inv.setItem(i, glass);
if (inv.getItem(i) == null) inv.setItem(i, glass);
}
} }
private ItemStack makeGlass() { private ItemStack makeGlass() {
ItemStack glass = new ItemStack(Material.GRAY_STAINED_GLASS_PANE); ItemStack g = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
ItemMeta meta = glass.getItemMeta(); ItemMeta m = g.getItemMeta();
if (meta != null) { meta.setDisplayName(" "); glass.setItemMeta(meta); } if (m != null) { m.setDisplayName(" "); g.setItemMeta(m); }
return glass; return g;
}
private ItemStack buildActionItem(Material material, String displayName, List<String> 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;
} }
} }

View File

@@ -1,6 +1,7 @@
package de.ticketsystem.manager; package de.ticketsystem.manager;
import de.ticketsystem.TicketPlugin; import de.ticketsystem.TicketPlugin;
import de.ticketsystem.model.FaqCategory;
import de.ticketsystem.model.FaqEntry; import de.ticketsystem.model.FaqEntry;
import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
@@ -10,27 +11,39 @@ import java.io.IOException;
import java.util.*; 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. * faqs.yml wird beim ersten Start automatisch mit Beispiel-Kategorien und -FAQs generiert.
* All changes are saved immediately to faqs.yml. * 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: * faqs:
* 1: * 1:
* question: "Wie erstelle ich ein Ticket?" * question: "Wie erstelle ich ein Ticket?"
* answer: "Nutze /ticket create [Kategorie] [Beschreibung]." * answer: "Nutze /ticket create ..."
* 2: * category: "tickets"
* question: "..." *
* answer: "..." * 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 { public class FaqManager {
public static final String UNCATEGORIZED_KEY = "__none__";
private final TicketPlugin plugin; private final TicketPlugin plugin;
private final File faqFile; private final File faqFile;
private YamlConfiguration faqConfig; private YamlConfiguration faqConfig;
private final List<FaqEntry> entries = new ArrayList<>(); private final List<FaqEntry> entries = new ArrayList<>();
private final LinkedHashMap<String, FaqCategory> categories = new LinkedHashMap<>();
private int nextId = 1; private int nextId = 1;
public FaqManager(TicketPlugin plugin) { public FaqManager(TicketPlugin plugin) {
@@ -43,6 +56,7 @@ public class FaqManager {
private void load() { private void load() {
entries.clear(); entries.clear();
categories.clear();
nextId = 1; nextId = 1;
if (!faqFile.exists()) { if (!faqFile.exists()) {
@@ -59,16 +73,37 @@ public class FaqManager {
} }
faqConfig = YamlConfiguration.loadConfiguration(faqFile); faqConfig = YamlConfiguration.loadConfiguration(faqFile);
ConfigurationSection section = faqConfig.getConfigurationSection("faqs");
if (section != null) { // ── 1. Kategorien laden ───────────────────────────────────────────
for (String key : section.getKeys(false)) { 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 { try {
int id = Integer.parseInt(key); int id = Integer.parseInt(key);
String question = faqConfig.getString("faqs." + key + ".question", ""); String question = faqConfig.getString("faqs." + key + ".question", "");
String answer = faqConfig.getString("faqs." + key + ".answer", ""); String answer = faqConfig.getString("faqs." + key + ".answer", "");
String category = faqConfig.getString("faqs." + key + ".category", null);
if (!question.isBlank() && !answer.isBlank()) { 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; if (id >= nextId) nextId = id + 1;
} }
} catch (NumberFormatException ignored) {} } catch (NumberFormatException ignored) {}
@@ -82,31 +117,56 @@ public class FaqManager {
} }
} }
/** Writes the example FAQs into a freshly created faqs.yml. */
private void loadDefaults() { 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?", 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] <Beschreibung>.", "tickets");
writeEntry(2, "Wie lange dauert die Bearbeitung?", 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?", 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?", writeEntry(4, "Wie kann ich meinen Support bewerten?",
"Nach dem Schließen eines Tickets kannst du mit /ticket rate <ID> good/bad eine Bewertung abgeben."); "Mit /ticket rate <ID> good/bad nach dem Schließen.", "tickets");
nextId = 5; nextId = 5;
// Sync entries list with what we just wrote Text muss identisch sein!
entries.add(new FaqEntry(1, "Wie erstelle ich ein Ticket?", for (int i = 1; i <= 4; i++) {
"Nutze den Befehl /ticket create [Kategorie] [Prio][Beschreibung] um ein neues Ticket zu erstellen.")); String q = faqConfig.getString("faqs." + i + ".question", "");
entries.add(new FaqEntry(2, "Wie lange dauert die Bearbeitung?", String a = faqConfig.getString("faqs." + i + ".answer", "");
"Unser Support-Team bearbeitet Tickets so schnell wie möglich. Bitte habe etwas Geduld.")); FaqEntry e = new FaqEntry(i, q, a);
entries.add(new FaqEntry(3, "Kann ich mein Ticket löschen?", e.setCategoryKey("tickets");
"Ja! Öffne /ticket list und klicke auf dein Ticket, um es aus der Übersicht zu entfernen.")); entries.add(e);
entries.add(new FaqEntry(4, "Wie kann ich meinen Support bewerten?", }
"Nach dem Schließen eines Tickets kannst du mit /ticket rate <ID> good/bad eine Bewertung abgeben."));
} }
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 + ".question", question);
faqConfig.set("faqs." + id + ".answer", answer); faqConfig.set("faqs." + id + ".answer", answer);
if (categoryKey != null && !categoryKey.equals(UNCATEGORIZED_KEY)) {
faqConfig.set("faqs." + id + ".category", categoryKey);
}
} }
private void save() { 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 boolean hasCategoriesEnabled() { return !categories.isEmpty(); }
public List<FaqEntry> getAll() {
return Collections.unmodifiableList(entries); public List<FaqCategory> getAllCategories() {
return Collections.unmodifiableList(new ArrayList<>(categories.values()));
} }
/** Looks up an entry by its numeric ID. Returns null if not found. */ public FaqCategory getCategoryByKey(String key) {
public FaqEntry getById(int id) { if (key == null) return null;
return entries.stream().filter(e -> e.getId() == id).findFirst().orElse(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. * @return null wenn der Schlüssel bereits existiert, sonst die neue FaqCategory.
* @param answer The answer text.
* @return The newly created {@link FaqEntry}.
*/ */
public FaqEntry add(String question, String answer) { public FaqCategory addCategory(String key, String name, String color, String description) {
int id = nextId++; String lowerKey = key.toLowerCase().replaceAll("\\s+", "_");
FaqEntry entry = new FaqEntry(id, question, answer); if (categories.containsKey(lowerKey)) return null;
entries.add(entry); FaqCategory cat = new FaqCategory(lowerKey, name, color, description);
writeEntry(id, question, answer); categories.put(lowerKey, cat);
writeCategory(lowerKey, name, color, description);
save(); 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) { public boolean editCategory(String key, String name, String color, String description) {
FaqEntry entry = getById(id); String lowerKey = key.toLowerCase();
if (entry == null) return false; if (!categories.containsKey(lowerKey)) return false;
entry.setQuestion(question); FaqCategory updated = new FaqCategory(lowerKey, name, color, description);
entry.setAnswer(answer); categories.put(lowerKey, updated);
writeEntry(id, question, answer); writeCategory(lowerKey, name, color, description);
save(); save();
return true; 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<FaqEntry> getByCategory(String categoryKey) {
String normalizedKey = normalizeCategoryKey(categoryKey);
List<FaqEntry> 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<FaqEntry> 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) { public boolean delete(int id) {
FaqEntry entry = getById(id); FaqEntry entry = getById(id);
if (entry == null) return false; if (entry == null) return false;
@@ -174,8 +310,20 @@ public class FaqManager {
return true; 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();
} }
} }

View File

@@ -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; }
}

View File

@@ -2,6 +2,9 @@ package de.ticketsystem.model;
/** /**
* Represents a single FAQ entry stored in faqs.yml. * 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 { public class FaqEntry {
@@ -9,6 +12,13 @@ public class FaqEntry {
private String question; private String question;
private String answer; 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) { public FaqEntry(int id, String question, String answer) {
this.id = id; this.id = id;
this.question = question; this.question = question;
@@ -21,9 +31,16 @@ public class FaqEntry {
public void setQuestion(String q) { this.question = q; } public void setQuestion(String q) { this.question = q; }
public String getAnswer() { return answer; } public String getAnswer() { return answer; }
public void setAnswer(String a) { this.answer = a; } 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 @Override
public String toString() { public String toString() {
return "FaqEntry{id=" + id + ", question='" + question + "'}"; return "FaqEntry{id=" + id + ", question='" + question + "', category='" + categoryKey + "'}";
} }
} }

View File

@@ -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<String, WebSession> 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<Map.Entry<String, WebSession>> 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);
}
}
}

View File

@@ -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.");
}
}
}

View File

@@ -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; }
}

View File

@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<TicketComment> 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<de.ticketsystem.model.FaqEntry> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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", "");
}
}

View File

@@ -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 <link rel="icon">-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 <link rel="icon">} HTML-Tag für den {@code <head>}-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 "<link rel='icon' type='" + mime + "' href='/static/" + escHtml(safe) + "'>";
}
// Standard-SVG als Data-URI (kein externes File nötig)
String svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'>"
+ "<rect width='48' height='48' rx='10' fill='%231d4ed8'/>"
+ "<rect x='8' y='14' width='32' height='6' rx='2' fill='white' opacity='.9'/>"
+ "<rect x='8' y='23' width='20' height='4' rx='2' fill='white' opacity='.6'/>"
+ "<rect x='8' y='30' width='14' height='4' rx='2' fill='white' opacity='.4'/>"
+ "<circle cx='37' cy='32' r='6' fill='white' opacity='.15'/>"
+ "<path d='M34 32l2 2 4-4' stroke='white' stroke-width='1.8'"
+ " stroke-linecap='round' stroke-linejoin='round'/>"
+ "</svg>";
String b64 = Base64.getEncoder().encodeToString(svg.getBytes(StandardCharsets.UTF_8));
return "<link rel='icon' type='image/svg+xml' href='data:image/svg+xml;base64," + b64 + "'>";
}
// ─────────────────────────── 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<String, String> parseQuery(String query) {
Map<String, String> 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<String, String> parseBody(HttpExchange ex) throws IOException {
try (InputStream is = ex.getRequestBody()) {
String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
return parseQuery(body);
}
}
protected Map<String, String> parseCookies(HttpExchange ex) {
Map<String, String> 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()
? "<a href='/faq' class='nav-link'>FAQ</a>" : "";
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()
? "<a href='/faq' class='nav-link'>" + escHtml(wl(plugin, "nav-faq")) + "</a>" : "";
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 "<!DOCTYPE html>"
+ "<html lang='de'>"
+ "<head>"
+ "<meta charset='UTF-8'>"
+ "<meta name='viewport' content='width=device-width,initial-scale=1'>"
+ "<title>" + escHtml(title) + " TicketSystem Panel</title>"
+ faviconTag
+ "<link rel='preconnect' href='https://fonts.googleapis.com'>"
+ "<link href='https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap' rel='stylesheet'>"
+ "<link rel='stylesheet' href='/static/style.css'>"
+ "</head>"
+ "<body>"
+ "<nav class='navbar'>"
+ "<div class='nav-brand'>🎫 TicketSystem</div>"
+ "<div class='nav-links'>"
+ "<a href='/dashboard' class='nav-link'>" + escHtml(navDashboard) + "</a>"
+ "<a href='/tickets' class='nav-link'>" + escHtml(navTickets) + "</a>"
+ faqNav
+ "</div>"
+ "<div class='nav-user'>"
+ "<div class='nav-avatar'>" + escHtml(initial) + "</div>"
+ "<span class='nav-username'>" + escHtml(username) + "</span>"
+ "<span class='badge badge-" + roleClass + "'>" + escHtml(roleLabel) + "</span>"
+ "<a href='/logout' class='btn btn-sm btn-secondary'>" + escHtml(navLogout) + "</a>"
+ "</div>"
+ "</nav>"
+ "<main class='container'>"
+ content
+ "</main>"
+ "<script src='/static/panel.js'></script>"
+ "</body>"
+ "</html>";
}
/**
* 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 """
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8">
<title>%s</title><link rel="stylesheet" href="/static/style.css"></head>
<body><div class="container" style="margin-top:4rem;text-align:center">
<h1>%s</h1><p>%s</p><a href="/dashboard" class="btn btn-primary">Zur\u00fcck</a>
</div></body></html>
""".formatted(title, title, message);
}
/**
* Einfache Fehlerseite mit lokalisiertem Zurück-Button.
*/
protected String errorPage(String title, String message, TicketPlugin plugin) {
return """
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8">
<title>%s</title><link rel="stylesheet" href="/static/style.css"></head>
<body><div class="container" style="margin-top:4rem;text-align:center">
<h1>%s</h1><p>%s</p><a href="/dashboard" class="btn btn-primary">%s</a>
</div></body></html>
""".formatted(title, title, message, wl(plugin, "btn-back"));
}
protected String escHtml(String s) {
if (s == null) return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
private String decode(String s) {
return URLDecoder.decode(s, StandardCharsets.UTF_8);
}
}

View File

@@ -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<Ticket> 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<Ticket> 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<Ticket> 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("<h1 class='page-title'>").append(escHtml(wl(plugin, "dash-title"))).append("</h1>");
// ── Stats ──
sb.append("<div class='stats-grid'>");
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("</div>");
// ── Letzte offene Tickets ──
sb.append("<div class='card'>");
sb.append("<div class='card-title'>").append(escHtml(wl(plugin, "dash-section-recent"))).append("</div>");
sb.append("<table><thead><tr>")
.append("<th>").append(escHtml(wl(plugin, "dash-col-id"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "dash-col-player"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "dash-col-category"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "dash-col-priority"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "dash-col-status"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "dash-col-created"))).append("</th>")
.append("<th></th>")
.append("</tr></thead><tbody>");
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("<tr>")
.append("<td><strong>#").append(t.getId()).append("</strong></td>")
.append("<td>").append(escHtml(t.getCreatorName())).append("</td>")
.append("<td>").append(escHtml(catName)).append("</td>")
.append("<td>").append(priorityBadge(t)).append("</td>")
.append("<td>").append(statusBadge(t)).append("</td>")
.append("<td>").append(created).append("</td>")
.append("<td><a href='/ticket/").append(t.getId())
.append("' class='btn btn-sm btn-secondary'>")
.append(escHtml(wl(plugin, "btn-details")))
.append("</a></td>")
.append("</tr>");
}
if (recent.isEmpty()) {
sb.append("<tr><td colspan='7' style='text-align:center;color:var(--muted);padding:2rem'>")
.append(escHtml(wl(plugin, "dash-empty")))
.append("</td></tr>");
}
sb.append("</tbody></table></div>");
// ── Top Ersteller (nur wenn Daten vorhanden) ──
if (!stats.byPlayer.isEmpty()) {
sb.append("<div class='card'><div class='card-title'>")
.append(escHtml(wl(plugin, "dash-section-top")))
.append("</div>");
sb.append("<table><thead><tr>")
.append("<th>").append(escHtml(wl(plugin, "dash-col-player"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "dash-col-count"))).append("</th>")
.append("</tr></thead><tbody>");
stats.byPlayer.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.limit(10)
.forEach(e -> sb.append("<tr><td>").append(escHtml(e.getKey()))
.append("</td><td>").append(e.getValue()).append("</td></tr>"));
sb.append("</tbody></table></div>");
}
// ── BungeeCord: Tickets pro Server ──
if (plugin.isBungeeCordEnabled() && !stats.byServer.isEmpty()) {
sb.append("<div class='card'><div class='card-title'>")
.append(escHtml(wl(plugin, "dash-section-server")))
.append("</div>");
sb.append("<table><thead><tr>")
.append("<th>").append(escHtml(wl(plugin, "dash-col-server"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "dash-col-count"))).append("</th>")
.append("</tr></thead><tbody>");
stats.byServer.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.forEach(e -> sb.append("<tr><td>").append(escHtml(e.getKey()))
.append("</td><td>").append(e.getValue()).append("</td></tr>"));
sb.append("</tbody></table></div>");
}
return sb.toString();
}
private void statCard(StringBuilder sb, String value, String label, String colorClass) {
sb.append("<div class='stat-card'><div class='stat-value ").append(colorClass).append("'>")
.append(escHtml(value)).append("</div><div class='stat-label'>").append(escHtml(label))
.append("</div></div>");
}
private String statusBadge(Ticket t) {
String s = t.getStatus().name().toLowerCase();
return "<span class='badge badge-" + s + "'>" + escHtml(t.getStatus().getDisplayName()) + "</span>";
}
private String priorityBadge(Ticket t) {
String p = t.getPriority().name().toLowerCase();
return "<span class='badge badge-" + p + "'>" + escHtml(t.getPriority().getDisplayName()) + "</span>";
}
private String getCategoryName(Ticket t) {
var cat = plugin.getCategoryManager().fromKey(t.getCategoryKey());
return cat != null ? cat.getName() : t.getCategoryKey();
}
}

View File

@@ -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<FaqEntry> entries = faq.getAll();
List<FaqCategory> 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("<div style='display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem'>");
sb.append("<h1 class='page-title' style='margin:0'>")
.append(escHtml(titleText)).append(" <span>").append(entries.size())
.append(" ").append(escHtml(entriesSufx)).append("</span></h1>");
sb.append("<button class='btn btn-primary' onclick='openModal(\"modal-add-faq\")'>");
sb.append(escHtml(btnAddText)).append("</button>");
sb.append("</div>");
// ── Kategorien-Übersicht ──
if (hasCats) {
sb.append("<div class='card'><div class='card-title'>")
.append(escHtml(wl(plugin, "faq-section-cats"))).append("</div>");
sb.append("<div style='display:flex;flex-wrap:wrap;gap:.5rem'>");
for (FaqCategory cat : categories) {
int count = faq.countByCategory(cat.getKey());
sb.append("<span class='badge' style='background:var(--surface2);color:var(--text);font-size:.85rem;padding:.35rem .75rem'>")
.append(escHtml(cat.getName()))
.append(" <span style='color:var(--muted)'>").append(count).append("</span></span>");
}
int uncatCount = faq.countByCategory(FaqManager.UNCATEGORIZED_KEY);
if (uncatCount > 0) {
sb.append("<span class='badge' style='background:var(--surface2);color:var(--muted);font-size:.85rem;padding:.35rem .75rem'>")
.append(escHtml(wl(plugin, "faq-no-category")))
.append(" <span>").append(uncatCount).append("</span></span>");
}
sb.append("</div></div>");
}
// ── FAQ-Einträge ──
sb.append("<div class='card'>");
for (FaqEntry e : entries) {
String catLabel = "";
if (hasCats && e.hasCategory()) {
FaqCategory cat = faq.getCategoryByKey(e.getCategoryKey());
catLabel = cat != null
? " <span class='badge' style='background:var(--surface2);color:var(--muted);font-size:.72rem'>"
+ escHtml(cat.getName()) + "</span>"
: "";
}
sb.append("<div class='faq-entry'>");
sb.append("<span class='faq-id'>#").append(e.getId()).append("</span>");
sb.append("<div style='flex:1'>");
sb.append("<div class='faq-q'>").append(escHtml(e.getQuestion())).append(catLabel).append("</div>");
sb.append("<div class='faq-a'>").append(escHtml(e.getAnswer())).append("</div>");
sb.append("</div>");
sb.append("<div class='faq-actions'>");
sb.append("<button class='btn btn-sm btn-secondary' onclick='openFaqEdit(")
.append(e.getId()).append(",`").append(escJs(e.getQuestion()))
.append("`,`").append(escJs(e.getAnswer())).append("`,`")
.append(e.hasCategory() ? e.getCategoryKey() : "").append("`)'>✏️</button>");
sb.append("<button class='btn btn-sm btn-danger' onclick='deleteFaq(").append(e.getId()).append(")'>🗑</button>");
sb.append("</div></div>");
}
if (entries.isEmpty()) {
sb.append("<div style='text-align:center;color:var(--muted);padding:2rem'>")
.append(escHtml(wl(plugin, "faq-empty"))).append("</div>");
}
sb.append("</div>");
// ── Modal: FAQ hinzufügen ──
sb.append("<div class=\"modal-backdrop\" id=\"modal-add-faq\" onclick=\"if(event.target===this)closeModal('modal-add-faq')\">");
sb.append(" <div class=\"modal\">");
sb.append(" <div class=\"modal-title\">").append(escHtml(wl(plugin, "faq-modal-add-title"))).append("</div>");
sb.append(" <div class=\"form-group\"><label>").append(escHtml(wl(plugin, "faq-label-question"))).append("</label>");
sb.append(" <input id=\"new-faq-q\" placeholder=\"").append(escHtml(wl(plugin, "faq-ph-question"))).append("\"></div>");
sb.append(" <div class=\"form-group\"><label>").append(escHtml(wl(plugin, "faq-label-answer"))).append("</label>");
sb.append(" <textarea id=\"new-faq-a\" placeholder=\"").append(escHtml(wl(plugin, "faq-ph-answer"))).append("\"></textarea></div>");
if (hasCats) {
sb.append("<div class='form-group'><label>").append(escHtml(wl(plugin, "faq-label-category"))).append("</label>");
sb.append("<select id='new-faq-cat'>");
sb.append("<option value=''>").append(escHtml(wl(plugin, "faq-no-cat-opt"))).append("</option>");
for (FaqCategory cat : categories) {
sb.append("<option value='").append(escHtml(cat.getKey())).append("'>")
.append(escHtml(cat.getName())).append("</option>");
}
sb.append("</select></div>");
} else {
sb.append("<input type='hidden' id='new-faq-cat' value=''>");
}
sb.append(" <div class=\"modal-actions\">");
sb.append(" <button class=\"btn btn-secondary\" onclick=\"closeModal('modal-add-faq')\">")
.append(escHtml(wl(plugin, "faq-btn-cancel"))).append("</button>");
sb.append(" <button class=\"btn btn-primary\" onclick=\"submitFaqAdd()\">")
.append(escHtml(wl(plugin, "faq-btn-add-confirm"))).append("</button>");
sb.append(" </div>");
sb.append(" </div>");
sb.append("</div>");
// ── Modal: FAQ bearbeiten ──
sb.append("<div class=\"modal-backdrop\" id=\"modal-edit-faq\" onclick=\"if(event.target===this)closeModal('modal-edit-faq')\">");
sb.append(" <div class=\"modal\">");
sb.append(" <div class=\"modal-title\">").append(escHtml(wl(plugin, "faq-modal-edit-title"))).append("</div>");
sb.append(" <input type=\"hidden\" id=\"edit-faq-id\">");
sb.append(" <div class=\"form-group\"><label>").append(escHtml(wl(plugin, "faq-label-question"))).append("</label>");
sb.append(" <input id=\"edit-faq-q\"></div>");
sb.append(" <div class=\"form-group\"><label>").append(escHtml(wl(plugin, "faq-label-answer"))).append("</label>");
sb.append(" <textarea id=\"edit-faq-a\"></textarea></div>");
if (hasCats) {
sb.append("<div class='form-group'><label>").append(escHtml(wl(plugin, "faq-label-category"))).append("</label>");
sb.append("<select id='edit-faq-cat'>");
sb.append("<option value=''>").append(escHtml(wl(plugin, "faq-no-cat-opt"))).append("</option>");
for (FaqCategory cat : categories) {
sb.append("<option value='").append(escHtml(cat.getKey())).append("'>")
.append(escHtml(cat.getName())).append("</option>");
}
sb.append("</select></div>");
} else {
sb.append("<input type='hidden' id='edit-faq-cat' value=''>");
}
sb.append(" <div class=\"modal-actions\">");
sb.append(" <button class=\"btn btn-secondary\" onclick=\"closeModal('modal-edit-faq')\">")
.append(escHtml(wl(plugin, "faq-btn-cancel"))).append("</button>");
sb.append(" <button class=\"btn btn-primary\" onclick=\"submitFaqEdit()\">")
.append(escHtml(wl(plugin, "faq-btn-save"))).append("</button>");
sb.append(" </div>");
sb.append(" </div>");
sb.append("</div>");
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("$", "\\$");
}
}

View File

@@ -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<String, String> 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 <img>-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 "<img src='/static/" + escHtml(safeName) + "' class='" + sizeClass + "' alt='Logo' "
+ "style='object-fit:contain;border-radius:16px;'>";
}
// Standard-SVG-Icon (Ticket-Symbol)
return "<svg class='" + sizeClass + "' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg' "
+ "style='background:linear-gradient(135deg,#3b82f6,#1d4ed8);border-radius:14px;padding:6px;'>"
+ "<rect x='8' y='14' width='32' height='6' rx='2' fill='white' opacity='.9'/>"
+ "<rect x='8' y='23' width='20' height='4' rx='2' fill='white' opacity='.6'/>"
+ "<rect x='8' y='30' width='14' height='4' rx='2' fill='white' opacity='.4'/>"
+ "<circle cx='37' cy='32' r='6' fill='white' opacity='.15'/>"
+ "<path d='M34 32l2 2 4-4' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/>"
+ "</svg>";
}
// ─────────────────────────── Login-Seite ────────────────────────────────
private String loginPage(String error) {
String errorHtml = error.isEmpty() ? "" :
"<div class='login-alert'>" + escHtml(error) + "</div>";
String logoLeft = buildLogoHtml("logo-icon-lg");
String logoRight = buildLogoHtml("logo-icon-sm");
return "<!DOCTYPE html>"
+ "<html lang='de'>"
+ "<head>"
+ "<meta charset='UTF-8'>"
+ "<meta name='viewport' content='width=device-width,initial-scale=1'>"
+ "<title>" + escHtml(wl(plugin, "login-title")) + "</title>"
+ buildFaviconHtml(plugin)
+ "<link rel='preconnect' href='https://fonts.googleapis.com'>"
+ "<link href='https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap' rel='stylesheet'>"
+ "<link rel='stylesheet' href='/static/style.css'>"
+ "<style>"
+ loginCss()
+ "</style>"
+ "</head>"
+ "<body class='login-body'>"
+ "<div class='login-split'>"
// ── Linke Seite ──────────────────────────────────────────────
+ " <div class='login-left'>"
+ " <div class='login-left-inner'>"
+ " " + logoLeft
+ " <div class='login-brand'>" + escHtml(getBrandName()) + "</div>"
+ " <div class='login-tagline'>" + escHtml(getTagline()) + "</div>"
+ " </div>"
+ " </div>"
// ── Rechte Seite ─────────────────────────────────────────────
+ " <div class='login-right'>"
+ " <div class='login-form-wrap'>"
+ " " + logoRight
+ " <h1 class='login-heading'>" + escHtml(wl(plugin, "login-heading")) + "</h1>"
+ " <p class='login-sub'>" + escHtml(wl(plugin, "login-sub")) + "</p>"
+ errorHtml
+ " <form method='POST' action='/login' class='login-form'>"
+ " <div class='lf-group'>"
+ " <label class='lf-label' for='username'>" + escHtml(wl(plugin, "login-label-user")) + "</label>"
+ " <input class='lf-input' type='text' id='username' name='username' required autofocus autocomplete='username'>"
+ " </div>"
+ " <div class='lf-group'>"
+ " <label class='lf-label' for='password'>" + escHtml(wl(plugin, "login-label-pass")) + "</label>"
+ " <input class='lf-input' type='password' id='password' name='password' required autocomplete='current-password'>"
+ " </div>"
+ " <button type='submit' class='login-btn'>" + escHtml(wl(plugin, "login-btn")) + "</button>"
+ " </form>"
+ " <div class='login-footer'>" + escHtml(wl(plugin, "login-footer")) + "</div>"
+ " </div>"
+ " </div>"
+ "</div>"
+ "</body>"
+ "</html>";
}
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;}"
+ "}";
}
}

View File

@@ -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<String, String> 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();
}
}

View File

@@ -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<String, String> 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<Ticket> all = db.getAllTickets();
// ── Filter ──
List<Ticket> 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<Ticket> 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<Ticket> tickets, int total, int page, int totalPages,
String filterStatus, String filterCat, String filterPrio, String filterSearch) {
StringBuilder sb = new StringBuilder();
sb.append("<h1 class='page-title'>")
.append(escHtml(wl(plugin, "tickets-title")))
.append(" <span>").append(total).append(" ")
.append(escHtml(wl(plugin, "tickets-total")))
.append("</span></h1>");
// ── Filter-Bar ──
sb.append("<div class='filter-bar'>");
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<Map.Entry<String,String>> 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("<form method='GET' action='/tickets' style='display:flex;gap:.5rem'>");
sb.append("<input name='q' placeholder='")
.append(escHtml(wl(plugin, "tickets-search-ph")))
.append("' value='").append(escHtml(filterSearch)).append("'style='width:180px'>");
sb.append("<input name='status' type='hidden' value='").append(escHtml(filterStatus)).append("'>");
sb.append("<input name='category' type='hidden' value='").append(escHtml(filterCat)).append("'>");
sb.append("<input name='priority' type='hidden' value='").append(escHtml(filterPrio)).append("'>");
sb.append("<button type='submit' class='btn btn-secondary btn-sm'>🔍</button></form>");
sb.append("</div>");
// ── Tabelle ──
sb.append("<div class='card'><table><thead><tr>")
.append("<th>").append(escHtml(wl(plugin, "tickets-col-id"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "tickets-col-player"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "tickets-col-message"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "tickets-col-cat"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "tickets-col-prio"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "tickets-col-status"))).append("</th>")
.append("<th>").append(escHtml(wl(plugin, "tickets-col-created"))).append("</th>")
.append("<th></th>")
.append("</tr></thead><tbody>");
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("<tr>")
.append("<td><strong>#").append(t.getId()).append("</strong></td>")
.append("<td>").append(escHtml(t.getCreatorName())).append("</td>")
.append("<td>").append(escHtml(msg)).append("</td>")
.append("<td>").append(escHtml(catName)).append("</td>")
.append("<td>").append(priorityBadge(t)).append("</td>")
.append("<td>").append(statusBadge(t)).append("</td>")
.append("<td style='white-space:nowrap'>").append(created).append("</td>")
.append("<td><a href='/ticket/").append(t.getId())
.append("' class='btn btn-sm btn-secondary'>")
.append(escHtml(wl(plugin, "btn-details")))
.append("</a></td>")
.append("</tr>");
}
if (tickets.isEmpty()) {
sb.append("<tr><td colspan='8' style='text-align:center;color:var(--muted);padding:2rem'>")
.append(escHtml(wl(plugin, "tickets-empty")))
.append("</td></tr>");
}
sb.append("</tbody></table></div>");
// ── Paginierung ──
if (totalPages > 1) {
sb.append("<div class='pagination'>");
for (int i = 1; i <= totalPages; i++) {
String active = i == page ? " active" : "";
sb.append("<a class='page-btn").append(active).append("' href='/tickets?page=").append(i)
.append("&status=").append(filterStatus)
.append("&category=").append(filterCat)
.append("&priority=").append(filterPrio)
.append("&q=").append(escHtml(filterSearch))
.append("'>").append(i).append("</a>");
}
sb.append("</div>");
}
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<TicketComment> 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<TicketComment> comments, WebSession session) {
StringBuilder sb = new StringBuilder();
String created = t.getCreatedAt() != null ? SDF.format(t.getCreatedAt()) : "";
String catName = getCategoryName(t);
// ── Header ──
sb.append("<div style='margin-bottom:1.5rem'>");
sb.append("<a href='/tickets' class='btn btn-sm btn-secondary' style='margin-bottom:1rem'>")
.append(escHtml(wl(plugin, "detail-back"))).append("</a>");
sb.append("</div>");
sb.append("<div class='ticket-header'>");
sb.append("<div class='ticket-id'>#").append(t.getId()).append("</div>");
sb.append("<div class='ticket-meta'>");
sb.append(statusBadge(t)).append(" ").append(priorityBadge(t));
sb.append("<span class='badge' style='background:var(--surface2);color:var(--muted)'>")
.append(escHtml(catName)).append("</span>");
if (plugin.isBungeeCordEnabled()) {
sb.append("<span class='badge' style='background:var(--surface2);color:var(--muted)'>🌐 ")
.append(escHtml(t.getServerName())).append("</span>");
}
sb.append("</div></div>");
// ── Info-Grid ──
sb.append("<div class='info-grid'>");
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("</div>");
// ── Nachricht ──
sb.append("<div class='card-title'>").append(escHtml(wl(plugin, "detail-section-msg"))).append("</div>");
sb.append("<div class='ticket-message'>").append(escHtml(t.getMessage())).append("</div>");
if (t.getCloseComment() != null && !t.getCloseComment().isEmpty()) {
sb.append("<div class='card-title'>").append(escHtml(wl(plugin, "detail-section-closecomment"))).append("</div>");
sb.append("<div class='ticket-message'>").append(escHtml(t.getCloseComment())).append("</div>");
}
// ── Aktionen (nur bei aktiven Tickets) ──
if (t.getStatus() != TicketStatus.CLOSED) {
sb.append("<div class='card'><div class='card-title'>")
.append(escHtml(wl(plugin, "detail-section-actions")))
.append("</div><div style='display:flex;flex-wrap:wrap;gap:.75rem'>");
// Claim
if (t.getStatus() == TicketStatus.OPEN) {
sb.append("<button class='btn btn-success' onclick='claimTicket(").append(t.getId()).append(")'>")
.append(escHtml(wl(plugin, "detail-btn-claim"))).append("</button>");
}
// Priorität
if (plugin.getConfig().getBoolean("priorities-enabled", true)) {
sb.append("<select id='prio-select-").append(t.getId())
.append("' class='btn btn-secondary' style='width:auto' onchange='setPriority(")
.append(t.getId()).append(", this.value)'>");
for (TicketPriority p : TicketPriority.values()) {
String sel = p == t.getPriority() ? " selected" : "";
sb.append("<option value='").append(p.name()).append("'").append(sel).append(">")
.append(escHtml(p.getDisplayName())).append("</option>");
}
sb.append("</select>");
}
// Weiterleiten (nur Admin)
if (session.isAdmin()) {
sb.append("<input id='forward-target-").append(t.getId())
.append("' class='btn btn-secondary' style='width:180px' placeholder='")
.append(escHtml(wl(plugin, "detail-ph-forward"))).append("'>");
sb.append("<button class='btn btn-warning' onclick='forwardTicket(").append(t.getId()).append(")'>")
.append(escHtml(wl(plugin, "detail-btn-forward"))).append("</button>");
}
// Schließen
sb.append("<div style='display:flex;gap:.5rem;align-items:center'>");
sb.append("<input id='close-comment-").append(t.getId())
.append("' placeholder='").append(escHtml(wl(plugin, "detail-ph-comment")))
.append("' style='width:220px'>");
sb.append("<button class='btn btn-danger' onclick='closeTicket(").append(t.getId()).append(")'>")
.append(escHtml(wl(plugin, "detail-btn-close"))).append("</button>");
sb.append("</div>");
sb.append("</div></div>");
}
// ── Kommentare ──
sb.append("<div class='card'><div class='card-title'>")
.append(escHtml(wl(plugin, "detail-section-comments")))
.append(" (").append(comments.size()).append(")</div>");
sb.append("<div class='comments'>");
for (TicketComment c : comments) {
String cTime = c.getCreatedAt() != null ? SDF.format(c.getCreatedAt()) : "";
sb.append("<div class='comment'>");
sb.append("<span class='comment-author'>").append(escHtml(c.getAuthorName())).append("</span>");
sb.append("<span class='comment-time'>").append(cTime).append("</span>");
sb.append("<div class='comment-text'>").append(escHtml(c.getMessage())).append("</div>");
sb.append("</div>");
}
if (comments.isEmpty()) {
sb.append("<div style='color:var(--muted);font-size:.875rem'>")
.append(escHtml(wl(plugin, "detail-no-comments"))).append("</div>");
}
sb.append("</div>");
// Neuer Kommentar
if (t.getStatus() != TicketStatus.CLOSED) {
sb.append("<div style='display:flex;gap:.75rem;margin-top:1rem'>");
sb.append("<textarea id='comment-input-").append(t.getId())
.append("' placeholder='").append(escHtml(wl(plugin, "detail-ph-newcomment")))
.append("' style='flex:1;min-height:60px'></textarea>");
sb.append("<button class='btn btn-primary' onclick='addComment(").append(t.getId()).append(")'>")
.append(escHtml(wl(plugin, "detail-btn-send"))).append("</button>");
sb.append("</div>");
}
sb.append("</div>");
return sb.toString();
}
// ─────────────────────────── Hilfsmethoden ─────────────────────────────
private String statusBadge(Ticket t) {
String s = t.getStatus().name().toLowerCase();
return "<span class='badge badge-" + s + "'>" + escHtml(t.getStatus().getDisplayName()) + "</span>";
}
private String priorityBadge(Ticket t) {
String p = t.getPriority().name().toLowerCase();
return "<span class='badge badge-" + p + "'>" + escHtml(t.getPriority().getDisplayName()) + "</span>";
}
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("<div class='info-row'><span class='info-label'>").append(escHtml(label)).append("</span>")
.append("<span>").append(escHtml(value != null ? value : "")).append("</span></div>");
}
private String selectFilter(String name, String current, List<Map.Entry<String,String>> options) {
StringBuilder sb = new StringBuilder("<select name='").append(name)
.append("' class='btn btn-secondary btn-sm' onchange='this.form.submit()'>");
for (var opt : options) {
String sel = opt.getKey().equals(current) ? " selected" : "";
sb.append("<option value='").append(escHtml(opt.getKey())).append("'").append(sel).append(">")
.append(escHtml(opt.getValue())).append("</option>");
}
return sb.append("</select>").toString();
}
private static Map.Entry<String,String> 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; }
}
}

View File

@@ -14,7 +14,7 @@
# --- GRUNDLEGEND --- # --- GRUNDLEGEND ---
# Version der Konfigurationsdatei. Nicht ändern! # Version der Konfigurationsdatei. Nicht ändern!
version: "2.2" version: "2.4"
# ---------------------------------------------------- # ----------------------------------------------------
# SPRACHE / LANGUAGE # SPRACHE / LANGUAGE
@@ -100,36 +100,20 @@ cache-ttl-seconds: 60 # Wie lange Tickets im In-Memory-Cache gehalten
# ---------------------------------------------------- # ----------------------------------------------------
# Kategorie-System (true = aktiviert) # Kategorie-System (true = aktiviert)
# Spieler können beim Erstellen eine Kategorie wählen: /ticket create [kategorie] [priorität] <text>
categories-enabled: true categories-enabled: true
# Prioritäten-System (true = aktiviert) # Prioritäten-System (true = aktiviert)
# Spieler können beim Erstellen eine Priorität wählen: /ticket create [kategorie] [priorität] <text>
# Admins/Supporter können die Priorität nachträglich ändern: /ticket setpriority <id> <low|normal|high|urgent>
priorities-enabled: true priorities-enabled: true
# Dürfen normale Spieler beim Erstellen eine Priorität setzen? # 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 allow-players-to-set-priority: false
# Bewertungs-System (true = aktiviert) # Bewertungs-System (true = aktiviert)
# Spieler können nach dem Schließen den Support bewerten: /ticket rate <id> good|bad
# Ergebnisse sind in /ticket stats sichtbar
rating-enabled: true rating-enabled: true
# ---------------------------------------------------- # ----------------------------------------------------
# KATEGORIEN (nur aktiv wenn categories-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: categories:
general: general:
name: "Allgemein" name: "Allgemein"
@@ -240,13 +224,24 @@ gui-settings:
# Beispiel: content-slots: [1, 3, 5, 7, 10, 12, 14, 16] -> Nur ungerade Slots # Beispiel: content-slots: [1, 3, 5, 7, 10, 12, 14, 16] -> Nur ungerade Slots
content-slots: [] content-slots: []
# Kopfeinstellungen # Kopfeinstellungen für FAQ-EINTRÄGE
head-item: head-item:
# Material des FAQ-Items (z.B. PLAYER_HEAD, BOOK, PAPER) # Material des FAQ-Items (z.B. PLAYER_HEAD, BOOK, PAPER)
material: PLAYER_HEAD material: PLAYER_HEAD
# Optional: Texture-URL für den Kopf (wenn Material PLAYER_HEAD) # Optional: Texture-URL für den Kopf (wenn Material PLAYER_HEAD)
texture: "http://textures.minecraft.net/texture/da2fde34d34c8588e58bfd790ce18025f7843399dee2ab4cedc2c0b463fd1e" 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) # Navigations-Slots (Prev, Next, Add, Page)
nav: nav:
prev: 45 prev: 45
@@ -292,3 +287,52 @@ gui-settings:
nav-back: ARROW nav-back: ARROW
nav-filter: HOPPER nav-filter: HOPPER
nav-add: LIME_WOOL nav-add: LIME_WOOL
# ============================================================
# WEB-PANEL
# ============================================================
# Ermöglicht die Verwaltung von Tickets über den Browser.
# Zugriff: http://<server-ip>:<port>
#
# 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"

View File

@@ -237,6 +237,7 @@ faq:
created: "&aFAQ &e#{id} &awurde erfolgreich erstellt!" created: "&aFAQ &e#{id} &awurde erfolgreich erstellt!"
created-question: "&7Frage: &e{question}" created-question: "&7Frage: &e{question}"
created-answer: "&7Antwort: &f{answer}" created-answer: "&7Antwort: &f{answer}"
created-category-info: "&7Kategorie: {category}"
updated: "&aFAQ &e#{id} &awurde erfolgreich aktualisiert!" updated: "&aFAQ &e#{id} &awurde erfolgreich aktualisiert!"
deleted: "&aFAQ &e#{id} &awurde gelöscht." deleted: "&aFAQ &e#{id} &awurde gelöscht."
not-found: "&cFAQ &e#{id} &cwurde nicht gefunden." not-found: "&cFAQ &e#{id} &cwurde nicht gefunden."
@@ -250,6 +251,21 @@ faq:
hint-open: "&7Benutze &e{cmd_faq} &7zum Öffnen der GUI." hint-open: "&7Benutze &e{cmd_faq} &7zum Öffnen der GUI."
admin-commands: "&7Admin-Befehle: &e{cmd_faq} add | edit | delete | reload | list" admin-commands: "&7Admin-Befehle: &e{cmd_faq} add | edit | delete | reload | list"
# ============================================================
# FAQ-KATEGORIEN BEFEHL (/ticket kategorie)
# ============================================================
faqcat:
usage: "&cBenutzung: /ticket kategorie <add|delete|list>"
usage-add: "&cBenutzung: /ticket kategorie add <n> [&Farbe] [Beschreibung]"
usage-delete: "&cBenutzung: /ticket kategorie delete <Schlüssel>"
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) # HILFE-MENÜ (/ticket ohne Argumente)
# ============================================================ # ============================================================
@@ -407,7 +423,7 @@ gui:
nav-filter-click: "§8Klicken zum Wechseln" nav-filter-click: "§8Klicken zum Wechseln"
nav-filter-all: "§7Alle (kein Filter)" nav-filter-all: "§7Alle (kein Filter)"
# ── FAQ GUI Texte (Neu) ───────────────────────────────── # ── FAQ GUI Texte ───────────────────────────────────────
faq: faq:
title: "&#FFD700&lHäufige Fragen (FAQ)" title: "&#FFD700&lHäufige Fragen (FAQ)"
admin-title: "§8§lFAQ verwalten" admin-title: "§8§lFAQ verwalten"
@@ -447,9 +463,61 @@ gui:
lore-id: "§7FAQ #{id}" lore-id: "§7FAQ #{id}"
lore-separator: "§8§m " lore-separator: "§8§m "
lore-category: "§7Kategorie: {category}"
question-set: "§7Frage gesetzt: §e{question}" question-set: "§7Frage gesetzt: §e{question}"
internal-error: "§cInterner Fehler beim Bearbeiten des FAQs." 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 <n>"
# ── 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 # JOIN-LISTENER
# ============================================================ # ============================================================
@@ -468,3 +536,118 @@ update:
available-bar: "====================================================" available-bar: "===================================================="
available-line1: "&6[TicketSystem] &eNEUES UPDATE VERFÜGBAR: v{version}" available-line1: "&6[TicketSystem] &eNEUES UPDATE VERFÜGBAR: v{version}"
available-line2: "&6[TicketSystem] &eDownload: https://www.spigotmc.org/resources/132757" 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"

View File

@@ -7,12 +7,11 @@
# Placeholders are written in curly braces: {id}, {player}, ... # Placeholders are written in curly braces: {id}, {player}, ...
# #
# Switch language in config.yml: language: en # Switch language in config.yml: language: en
# Switch command language in config.yml: command-language: de | en | both
# #
# {cmd_X} is automatically replaced based on command-language, e.g.: # {cmd_X} is automatically replaced based on language, e.g.:
# command-language: de → /ticket erstellen # language: de → /ticket erstellen
# command-language: en → /ticket create # language: en → /ticket create
# command-language: both → /ticket create (erstellen) # language: both → /ticket create (erstellen)
# ============================================================ # ============================================================
prefix: "&#5555FF[&fTicket&#5555FF] &r" prefix: "&#5555FF[&fTicket&#5555FF] &r"
@@ -183,10 +182,10 @@ stats:
ratings-header: "&6Support Ratings &7(total, historical)" ratings-header: "&6Support Ratings &7(total, historical)"
ratings-summary: "&a👍 Positive: &f{up} &c👎 Negative: &f{down}" ratings-summary: "&a👍 Positive: &f{up} &c👎 Negative: &f{down}"
ratings-percent: "&7Satisfaction: &e{percent}%" 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-table-header: "&7 Name 👍 👎 Tickets Satisfied"
staff-entry: "&e {name} &a{up} &c{down} &7{total} &e{percent}" 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}" server-entry: "&b {server}: &a{count}"
top-header: "&6Top-5 Ticket Creators &7(historical, persistent)" top-header: "&6Top-5 Ticket Creators &7(historical, persistent)"
top-empty: "&7No data available yet." top-empty: "&7No data available yet."
@@ -202,7 +201,7 @@ top:
header: "&6&lTop-5 Ticket Creators" header: "&6&lTop-5 Ticket Creators"
empty: "&7No data available yet." empty: "&7No data available yet."
entry: "{medal} &f{name} &e{count} &7{label}" 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 # RELOAD
@@ -238,28 +237,44 @@ faq:
created: "&aFAQ &e#{id} &ahas been created successfully!" created: "&aFAQ &e#{id} &ahas been created successfully!"
created-question: "&7Question: &e{question}" created-question: "&7Question: &e{question}"
created-answer: "&7Answer: &f{answer}" created-answer: "&7Answer: &f{answer}"
created-category-info: "&7Category: {category}"
updated: "&aFAQ &e#{id} &ahas been updated successfully!" updated: "&aFAQ &e#{id} &ahas been updated successfully!"
deleted: "&aFAQ &e#{id} &ahas been deleted." deleted: "&aFAQ &e#{id} &ahas been deleted."
not-found: "&cFAQ &e#{id} &cwas not found." not-found: "&cFAQ &e#{id} &cwas not found."
reloaded: "&aFAQs reloaded. ({count} entries)" 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-empty: "&7No FAQs available yet."
list-entry: "&e#{id} &f{question}" list-entry: "&e#{id} &f{question}"
list-answer: " &7→ &f{answer}" list-answer: " &7→ &f{answer}"
list-admin-hint: "&7Commands: &e{cmd_faq} add &8| &e{cmd_faq} edit <ID> &8| &e{cmd_faq} delete <ID>" list-admin-hint: "&7Commands: &e{cmd_faq} add &8| &e{cmd_faq} edit <ID> &8| &e{cmd_faq} delete <ID>"
unknown-sub: "&cUnknown FAQ command." unknown-sub: "&cUnknown FAQ subcommand."
hint-open: "&7Use &e{cmd_faq} &7to open the GUI." hint-open: "&7Use &e{cmd_faq} &7to open the GUI."
admin-commands: "&7Admin commands: &e{cmd_faq} add | edit | delete | reload | list" admin-commands: "&7Admin commands: &e{cmd_faq} add | edit | delete | reload | list"
# ============================================================
# FAQ CATEGORY COMMAND (/ticket category)
# ============================================================
faqcat:
usage: "&cUsage: /ticket category <add|delete|list>"
usage-add: "&cUsage: /ticket category add <n> [&Color] [Description]"
usage-delete: "&cUsage: /ticket category delete <key>"
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 MENU (/ticket without arguments)
# ============================================================ # ============================================================
help: help:
header: "&#00FF00&lTicketSystem &7 Commands" header: "&#00FFFF&lTicketSystem &7 Commands"
create: "&e{cmd_create} [category] <text> &7 Create a new ticket" create: "&e{cmd_create} [category] <text> &7 Create a new ticket"
list: "&e{cmd_list} &7 View your tickets (GUI)" list: "&e{cmd_list} &7 View your tickets (GUI)"
comment: "&e{cmd_comment} <ID> <text> &7 Add a message to a ticket" comment: "&e{cmd_comment} <ID> <text> &7 Add a message to a ticket"
rate: "&e{cmd_rate} <ID> <good|bad> &7 Rate the support" rate: "&e{cmd_rate} <ID> <good|bad> &7 Rate support"
claim: "&e{cmd_claim} <ID> &7 Claim a ticket" claim: "&e{cmd_claim} <ID> &7 Claim a ticket"
close: "&e{cmd_close} <ID> [comment] &7 Close a ticket" close: "&e{cmd_close} <ID> [comment] &7 Close a ticket"
forward: "&e{cmd_forward} <ID> <player> &7 Forward a ticket" forward: "&e{cmd_forward} <ID> <player> &7 Forward a ticket"
@@ -272,18 +287,18 @@ help:
# GUI TEXTS (TicketGUI) # GUI TEXTS (TicketGUI)
# ============================================================ # ============================================================
gui: gui:
# ── Chat messages ─────────────────────────────────────── # ── Chat Messages ───────────────────────────────────────
no-archive-permission: "&cYou don't have permission to open the archive." no-archive-permission: "&cYou don't have permission to view the archive."
no-tickets: "&aYou don't have any tickets right now." no-tickets: "&aYou currently have no tickets."
filter-label: "&7Filter: {filter}" filter-label: "&7Filter: {filter}"
ticket-removed: "&aYour ticket &e#{id} &ahas been removed from your overview." ticket-removed: "&aYour ticket &e#{id} &ahas been removed from your overview."
ticket-remove-error: "&cFailed to remove the ticket." 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." teleport-success: "&7You have been teleported to ticket &e#{id}&7."
world-not-loaded: "&cThe world of this ticket is not loaded!" world-not-loaded: "&cThe world of this ticket is not loaded!"
teleport-disabled: "&cCross-server teleport is disabled in the config.{hint}" teleport-disabled: "&cCross-server teleport is disabled in the config.{hint}"
teleport-unknown: "&cTicket server unknown teleport not possible." 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." bungee-connect-fail: "&cServer switch failed. Please connect manually."
no-delete-permission: "&cYou don't have permission to permanently delete tickets." no-delete-permission: "&cYou don't have permission to permanently delete tickets."
only-closed-deletable: "&cOnly closed tickets can be permanently deleted." only-closed-deletable: "&cOnly closed tickets can be permanently deleted."
@@ -292,18 +307,18 @@ gui:
already-closed: "&cThis ticket is already closed." already-closed: "&cThis ticket is already closed."
close-prompt-header: "&6Close ticket #{id}" close-prompt-header: "&6Close ticket #{id}"
close-prompt-hint: "&7Enter a comment (&e- &7for none)." 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-cancelled: "&cCancelled."
close-comment-echo: "&7Comment: &f{comment}" close-comment-echo: "&7Comment: &f{comment}"
no-priority-permission: "&cYou don't have permission to change the priority." no-priority-permission: "&cYou don't have permission to change the priority."
priority-closed: "&cThe priority of closed tickets cannot be changed." priority-closed: "&cThe priority of closed tickets cannot be changed."
priority-set: "&aPriority set to {priority}&a." priority-set: "&aPriority set to {priority}&a."
priority-error: "&cFailed to change the priority." 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-empty: "&7No comments yet."
comments-entry: "&e{author} &7({time})&8: &f{message}" comments-entry: "&e{author} &7({time})&8: &f{message}"
# ── Inventory titles ───────────────────────────────────── # ── Inventory Titles ─────────────────────────────────────
item: item:
title-admin: "§8§lTicket Overview" title-admin: "§8§lTicket Overview"
title-archive: "§8§lTicket Archive" title-archive: "§8§lTicket Archive"
@@ -408,7 +423,7 @@ gui:
nav-filter-click: "§8Click to cycle" nav-filter-click: "§8Click to cycle"
nav-filter-all: "§7All (no filter)" nav-filter-all: "§7All (no filter)"
# ── FAQ GUI Texts (New) ───────────────────────────────── # ── FAQ GUI Texts ─────────────────────────────────────────
faq: faq:
title: "&#00FF00&lFrequently Asked Questions (FAQ)" title: "&#00FF00&lFrequently Asked Questions (FAQ)"
admin-title: "§8§lManage FAQ" admin-title: "§8§lManage FAQ"
@@ -448,9 +463,61 @@ gui:
lore-id: "§7FAQ #{id}" lore-id: "§7FAQ #{id}"
lore-separator: "§8§m " lore-separator: "§8§m "
lore-category: "§7Category: {category}"
question-set: "§7Question set: §e{question}" question-set: "§7Question set: §e{question}"
internal-error: "§cInternal error while editing the FAQ." internal-error: "§cInternal error while editing the FAQ."
# ── Category selection screen ────────────────────────
cat-title: "&#00FF00&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 <n>"
# ── 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: "&#00FF00&lFAQ {category}"
admin-title-cat: "§8§lFAQ {category}"
title-cat-prefix: "&#00FF00&lFAQ"
admin-title-cat-prefix: "§8§lFAQ"
chat-category-hint: "§7Category: {category}"
# ============================================================ # ============================================================
# JOIN LISTENER # JOIN LISTENER
# ============================================================ # ============================================================
@@ -469,3 +536,118 @@ update:
available-bar: "====================================================" available-bar: "===================================================="
available-line1: "&6[TicketSystem] &eNEW UPDATE AVAILABLE: v{version}" available-line1: "&6[TicketSystem] &eNEW UPDATE AVAILABLE: v{version}"
available-line2: "&6[TicketSystem] &eDownload: https://www.spigotmc.org/resources/132757" 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"

View File

@@ -1,5 +1,5 @@
name: TicketSystem name: TicketSystem
version: 1.0.9 version: 1.1.2
main: de.ticketsystem.TicketPlugin main: de.ticketsystem.TicketPlugin
api-version: 1.20 api-version: 1.20
author: M_Viper author: M_Viper