Upload folder via GUI - src
This commit is contained in:
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,28 +11,40 @@ 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 {
|
||||||
|
|
||||||
private final TicketPlugin plugin;
|
public static final String UNCATEGORIZED_KEY = "__none__";
|
||||||
private final File faqFile;
|
|
||||||
private YamlConfiguration faqConfig;
|
private final TicketPlugin plugin;
|
||||||
private final List<FaqEntry> entries = new ArrayList<>();
|
private final File faqFile;
|
||||||
private int nextId = 1;
|
private YamlConfiguration faqConfig;
|
||||||
|
|
||||||
|
private final List<FaqEntry> entries = new ArrayList<>();
|
||||||
|
private final LinkedHashMap<String, FaqCategory> categories = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
private int nextId = 1;
|
||||||
|
|
||||||
public FaqManager(TicketPlugin plugin) {
|
public FaqManager(TicketPlugin plugin) {
|
||||||
this.plugin = plugin;
|
this.plugin = 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
57
src/main/java/de/ticketsystem/model/FaqCategory.java
Normal file
57
src/main/java/de/ticketsystem/model/FaqCategory.java
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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,21 +12,35 @@ 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;
|
||||||
this.answer = answer;
|
this.answer = answer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getId() { return id; }
|
public int getId() { return id; }
|
||||||
public void setId(int id) { this.id = id; }
|
public void setId(int id) { this.id = id; }
|
||||||
public String getQuestion() { return question; }
|
public String getQuestion() { return question; }
|
||||||
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 + "'}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
169
src/main/java/de/ticketsystem/web/SessionManager.java
Normal file
169
src/main/java/de/ticketsystem/web/SessionManager.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/main/java/de/ticketsystem/web/WebServer.java
Normal file
79
src/main/java/de/ticketsystem/web/WebServer.java
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/main/java/de/ticketsystem/web/WebSession.java
Normal file
39
src/main/java/de/ticketsystem/web/WebSession.java
Normal 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; }
|
||||||
|
}
|
||||||
332
src/main/java/de/ticketsystem/web/handlers/ApiHandler.java
Normal file
332
src/main/java/de/ticketsystem/web/handlers/ApiHandler.java
Normal 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", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
336
src/main/java/de/ticketsystem/web/handlers/BaseHandler.java
Normal file
336
src/main/java/de/ticketsystem/web/handlers/BaseHandler.java
Normal 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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String decode(String s) {
|
||||||
|
return URLDecoder.decode(s, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
164
src/main/java/de/ticketsystem/web/handlers/DashboardHandler.java
Normal file
164
src/main/java/de/ticketsystem/web/handlers/DashboardHandler.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/main/java/de/ticketsystem/web/handlers/FaqHandler.java
Normal file
182
src/main/java/de/ticketsystem/web/handlers/FaqHandler.java
Normal 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("$", "\\$");
|
||||||
|
}
|
||||||
|
}
|
||||||
304
src/main/java/de/ticketsystem/web/handlers/LoginHandler.java
Normal file
304
src/main/java/de/ticketsystem/web/handlers/LoginHandler.java
Normal 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;}"
|
||||||
|
+ "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
436
src/main/java/de/ticketsystem/web/handlers/StaticHandler.java
Normal file
436
src/main/java/de/ticketsystem/web/handlers/StaticHandler.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
379
src/main/java/de/ticketsystem/web/handlers/TicketsHandler.java
Normal file
379
src/main/java/de/ticketsystem/web/handlers/TicketsHandler.java
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -291,4 +286,53 @@ gui-settings:
|
|||||||
nav-archive: CHEST
|
nav-archive: CHEST
|
||||||
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"
|
||||||
@@ -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,9 +423,9 @@ 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"
|
||||||
action-title: "§8§lFAQ Aktionen"
|
action-title: "§8§lFAQ Aktionen"
|
||||||
|
|
||||||
@@ -424,32 +440,84 @@ gui:
|
|||||||
delete-button: "§c§lFAQ löschen"
|
delete-button: "§c§lFAQ löschen"
|
||||||
delete-lore-1: "§7Löscht diesen FAQ-Eintrag."
|
delete-lore-1: "§7Löscht diesen FAQ-Eintrag."
|
||||||
delete-lore-2: "§c§lACHTUNG: §cNicht rückgängig zu machen!"
|
delete-lore-2: "§c§lACHTUNG: §cNicht rückgängig zu machen!"
|
||||||
delete-error: "§cFehler: FAQ #{id} konnte nicht gelöscht werden."
|
delete-error: "§cFehler: FAQ #{id} konnte nicht gelöscht werden."
|
||||||
|
|
||||||
back-button: "§7§lZurück"
|
back-button: "§7§lZurück"
|
||||||
back-lore: "§7Zurück zur FAQ-Übersicht."
|
back-lore: "§7Zurück zur FAQ-Übersicht."
|
||||||
|
|
||||||
click-detail: "§e» Klicken für mehr Details im Chat"
|
click-detail: "§e» Klicken für mehr Details im Chat"
|
||||||
click-edit: "§e» Klicken zum Bearbeiten / Löschen"
|
click-edit: "§e» Klicken zum Bearbeiten / Löschen"
|
||||||
|
|
||||||
nav-prev: "§7§l◄ Zurück"
|
nav-prev: "§7§l◄ Zurück"
|
||||||
nav-prev-lore: "§7Seite {page} von {total}"
|
nav-prev-lore: "§7Seite {page} von {total}"
|
||||||
nav-next: "§7§lWeiter ►"
|
nav-next: "§7§lWeiter ►"
|
||||||
nav-next-lore: "§7Seite {page} von {total}"
|
nav-next-lore: "§7Seite {page} von {total}"
|
||||||
nav-page: "§8Seite {page}/{total}"
|
nav-page: "§8Seite {page}/{total}"
|
||||||
nav-page-lore: "§7Gesamt: {count} FAQ(s)"
|
nav-page-lore: "§7Gesamt: {count} FAQ(s)"
|
||||||
|
|
||||||
chat-create-title: "§6§lNeues FAQ erstellen"
|
chat-create-title: "§6§lNeues FAQ erstellen"
|
||||||
chat-question-prompt: "§7Gib die §eFrage §7ein (oder §ccancel§7):"
|
chat-question-prompt: "§7Gib die §eFrage §7ein (oder §ccancel§7):"
|
||||||
chat-answer-prompt: "§7Gib jetzt die §eAntwort §7ein (oder §ccancel§7):"
|
chat-answer-prompt: "§7Gib jetzt die §eAntwort §7ein (oder §ccancel§7):"
|
||||||
chat-edit-title: "§6§lFAQ #{id} bearbeiten"
|
chat-edit-title: "§6§lFAQ #{id} bearbeiten"
|
||||||
chat-current-question: "§7Aktuelle Frage: §e{question}"
|
chat-current-question: "§7Aktuelle Frage: §e{question}"
|
||||||
|
|
||||||
lore-id: "§7FAQ #{id}"
|
lore-id: "§7FAQ #{id}"
|
||||||
lore-separator: "§8§m "
|
lore-separator: "§8§m "
|
||||||
question-set: "§7Frage gesetzt: §e{question}"
|
lore-category: "§7Kategorie: {category}"
|
||||||
|
question-set: "§7Frage gesetzt: §e{question}"
|
||||||
internal-error: "§cInterner Fehler beim Bearbeiten des FAQs."
|
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
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -467,4 +535,119 @@ update:
|
|||||||
available-console: "Neue Version verfügbar: {new} (aktuell: {current})"
|
available-console: "Neue Version verfügbar: {new} (aktuell: {current})"
|
||||||
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"
|
||||||
@@ -6,13 +6,12 @@
|
|||||||
# HEX-Codes: &#RRGGBB (e.g. �AA00 = Green)
|
# HEX-Codes: &#RRGGBB (e.g. �AA00 = Green)
|
||||||
# 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: "ᖳFF[&fTicketᖳFF] &r"
|
prefix: "ᖳFF[&fTicketᖳFF] &r"
|
||||||
@@ -176,17 +175,17 @@ blacklist:
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
stats:
|
stats:
|
||||||
header: "&6Ticket Statistics"
|
header: "&6Ticket Statistics"
|
||||||
total: "&eTotal: &a{count}"
|
total: "&eTotal: &a{count}"
|
||||||
open: "&eOpen: &a{count}"
|
open: "&eOpen: &a{count}"
|
||||||
closed: "&eClosed: &a{count} &7(historical)"
|
closed: "&eClosed: &a{count} &7(historical)"
|
||||||
forwarded: "&eForwarded: &a{count}"
|
forwarded: "&eForwarded: &a{count}"
|
||||||
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: "�FF00&lTicketSystem &7– Commands"
|
header: "�FFFF&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,9 +423,9 @@ 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: "�FF00&lFrequently Asked Questions (FAQ)"
|
title: "�FF00&lFrequently Asked Questions (FAQ)"
|
||||||
admin-title: "§8§lManage FAQ"
|
admin-title: "§8§lManage FAQ"
|
||||||
action-title: "§8§lFAQ Actions"
|
action-title: "§8§lFAQ Actions"
|
||||||
|
|
||||||
@@ -425,32 +440,84 @@ gui:
|
|||||||
delete-button: "§c§lDelete FAQ"
|
delete-button: "§c§lDelete FAQ"
|
||||||
delete-lore-1: "§7Deletes this FAQ entry."
|
delete-lore-1: "§7Deletes this FAQ entry."
|
||||||
delete-lore-2: "§c§lWARNING: §cCannot be undone!"
|
delete-lore-2: "§c§lWARNING: §cCannot be undone!"
|
||||||
delete-error: "§cError: FAQ #{id} could not be deleted."
|
delete-error: "§cError: FAQ #{id} could not be deleted."
|
||||||
|
|
||||||
back-button: "§7§lBack"
|
back-button: "§7§lBack"
|
||||||
back-lore: "§7Back to FAQ overview."
|
back-lore: "§7Back to FAQ overview."
|
||||||
|
|
||||||
click-detail: "§e» Click for more details in chat"
|
click-detail: "§e» Click for more details in chat"
|
||||||
click-edit: "§e» Click to edit / delete"
|
click-edit: "§e» Click to edit / delete"
|
||||||
|
|
||||||
nav-prev: "§7§l◄ Previous"
|
nav-prev: "§7§l◄ Previous"
|
||||||
nav-prev-lore: "§7Page {page} of {total}"
|
nav-prev-lore: "§7Page {page} of {total}"
|
||||||
nav-next: "§7§lNext ►"
|
nav-next: "§7§lNext ►"
|
||||||
nav-next-lore: "§7Page {page} of {total}"
|
nav-next-lore: "§7Page {page} of {total}"
|
||||||
nav-page: "§8Page {page}/{total}"
|
nav-page: "§8Page {page}/{total}"
|
||||||
nav-page-lore: "§7Total: {count} FAQ(s)"
|
nav-page-lore: "§7Total: {count} FAQ(s)"
|
||||||
|
|
||||||
chat-create-title: "§6§lCreate new FAQ"
|
chat-create-title: "§6§lCreate new FAQ"
|
||||||
chat-question-prompt: "§7Enter the §eQuestion §7(or §ccancel§7):"
|
chat-question-prompt: "§7Enter the §eQuestion §7(or §ccancel§7):"
|
||||||
chat-answer-prompt: "§7Now enter the §eAnswer §7(or §ccancel§7):"
|
chat-answer-prompt: "§7Now enter the §eAnswer §7(or §ccancel§7):"
|
||||||
chat-edit-title: "§6§lEdit FAQ #{id}"
|
chat-edit-title: "§6§lEdit FAQ #{id}"
|
||||||
chat-current-question: "§7Current Question: §e{question}"
|
chat-current-question: "§7Current Question: §e{question}"
|
||||||
|
|
||||||
lore-id: "§7FAQ #{id}"
|
lore-id: "§7FAQ #{id}"
|
||||||
lore-separator: "§8§m "
|
lore-separator: "§8§m "
|
||||||
question-set: "§7Question set: §e{question}"
|
lore-category: "§7Category: {category}"
|
||||||
|
question-set: "§7Question set: §e{question}"
|
||||||
internal-error: "§cInternal error while editing the FAQ."
|
internal-error: "§cInternal error while editing the FAQ."
|
||||||
|
|
||||||
|
# ── Category selection screen ────────────────────────
|
||||||
|
cat-title: "�FF00&lFAQ – Select Category"
|
||||||
|
cat-admin-title: "§8§lFAQ – Manage Category"
|
||||||
|
|
||||||
|
cat-lore-separator: "§8§m "
|
||||||
|
cat-lore-count: "§7Entries: §e{count}"
|
||||||
|
cat-lore-click: "§e» Click to open"
|
||||||
|
cat-lore-admin-hint: "§8(Admin: Add new FAQ to this category)"
|
||||||
|
cat-lore-shift-hint: "§8Shift+Click to manage"
|
||||||
|
|
||||||
|
cat-back-button: "§7§l◄ Categories"
|
||||||
|
cat-back-lore: "§7Back to category selection."
|
||||||
|
|
||||||
|
cat-add-button: "§a§l+ New Category"
|
||||||
|
cat-add-lore-1: "§7Creates a new FAQ category."
|
||||||
|
cat-add-lore-2: "§7Use: /ticket category add <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: "�FF00&lFAQ – {category}"
|
||||||
|
admin-title-cat: "§8§lFAQ – {category}"
|
||||||
|
title-cat-prefix: "�FF00&lFAQ"
|
||||||
|
admin-title-cat-prefix: "§8§lFAQ"
|
||||||
|
chat-category-hint: "§7Category: {category}"
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# JOIN LISTENER
|
# JOIN LISTENER
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -468,4 +535,119 @@ update:
|
|||||||
available-console: "New version available: {new} (current: {current})"
|
available-console: "New version available: {new} (current: {current})"
|
||||||
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"
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user