Redesign the HTTPD

This commit is contained in:
2026-05-17 18:34:49 -04:00
parent 4fff172232
commit a92be6c681
26 changed files with 1909 additions and 235 deletions
+5
View File
@@ -88,6 +88,11 @@ public class HTTPDModule extends PlexModule
new CommandsEndpoint();
new SchematicDownloadEndpoint();
new SchematicUploadEndpoint();
new StatsEndpoint();
new PlayersEndpoint();
new AssetsEndpoint();
new PunishmentsUIEndpoint();
new IndefBansUIEndpoint();
ServletHolder uploadHolder = HTTPDModule.context.addServlet(SchematicUploadServlet.class, "/api/schematics/uploading");
@@ -13,6 +13,7 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.text.CharacterIterator;
import java.text.StringCharacterIterator;
import java.util.List;
@@ -39,7 +40,9 @@ public class AbstractServlet extends HttpServlet
}
GET_MAPPINGS.add(mapping);
ServletHolder holder = new ServletHolder(this);
HTTPDModule.context.addServlet(holder, getMapping.endpoint() + "*");
String endpoint = getMapping.endpoint();
String pattern = endpoint.endsWith("/") ? endpoint + "*" : endpoint;
HTTPDModule.context.addServlet(holder, pattern);
}
}
}
@@ -63,15 +66,22 @@ public class AbstractServlet extends HttpServlet
}*/
GET_MAPPINGS.stream().filter(mapping -> endpointMatchesRequest(mapping.getMapping().endpoint(), requestPath)).forEach(mapping ->
{
resp.setCharacterEncoding("UTF-8");
if (mapping.headers != null)
{
for (String headers : mapping.headers.headers())
{
String header = headers.split(";")[0];
String value = headers.split(";")[1];
resp.addHeader(header, value);
String[] parts = headers.split(";", 2);
if (parts.length == 2)
{
resp.addHeader(parts[0], parts[1]);
}
}
}
if (resp.getContentType() == null)
{
resp.setContentType("text/html; charset=UTF-8");
}
resp.setStatus(HttpServletResponse.SC_OK);
try
{
@@ -129,11 +139,10 @@ public class AbstractServlet extends HttpServlet
String page = readFileReal(filename);
String[] info = page.split("\n", 3);
base = base.replace("${TITLE}", info[0]);
base = base.replace("${ACTIVE_" + info[1] + "}", "active\" aria-current=\"page");
base = base.replace("${ACTIVE_" + info[1] + "}", "active");
base = base.replace("${ACTIVE_HOME}", "");
base = base.replace("${ACTIVE_ADMINS}", "");
base = base.replace("${ACTIVE_PLAYERS}", "");
base = base.replace("${ACTIVE_INDEFBANS}", "");
base = base.replace("${ACTIVE_LIST}", "");
base = base.replace("${ACTIVE_COMMANDS}", "");
base = base.replace("${ACTIVE_PUNISHMENTS}", "");
base = base.replace("${ACTIVE_SCHEMATICS}", "");
@@ -146,7 +155,7 @@ public class AbstractServlet extends HttpServlet
StringBuilder contentBuilder = new StringBuilder();
try
{
BufferedReader in = new BufferedReader(new InputStreamReader(Objects.requireNonNull(filename)));
BufferedReader in = new BufferedReader(new InputStreamReader(Objects.requireNonNull(filename), StandardCharsets.UTF_8));
String str;
while ((str = in.readLine()) != null)
{
@@ -0,0 +1,46 @@
package dev.plex.request.impl;
import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import dev.plex.request.MappingHeaders;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public class AssetsEndpoint extends AbstractServlet
{
@GetMapping(endpoint = "/assets/dashboard.js")
@MappingHeaders(headers = {"content-type;application/javascript; charset=utf-8", "cache-control;public, max-age=300"})
public String dashboardJs(HttpServletRequest request, HttpServletResponse response)
{
return readFileReal(this.getClass().getResourceAsStream("/httpd/assets/dashboard.js"));
}
@GetMapping(endpoint = "/assets/plexlogo.webp")
@MappingHeaders(headers = {"content-type;image/webp", "cache-control;public, max-age=86400"})
public String plexLogo(HttpServletRequest request, HttpServletResponse response)
{
serveResource("/httpd/assets/plexlogo.webp", response);
return null;
}
private static void serveResource(String classpathPath, HttpServletResponse response)
{
try (InputStream in = AssetsEndpoint.class.getResourceAsStream(classpathPath);
OutputStream out = response.getOutputStream())
{
if (in == null)
{
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
in.transferTo(out);
}
catch (IOException ignored)
{
}
}
}
@@ -6,11 +6,13 @@ import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandMap;
@@ -18,25 +20,31 @@ import org.bukkit.command.PluginIdentifiableCommand;
public class CommandsEndpoint extends AbstractServlet
{
private final StringBuilder list = new StringBuilder();
private boolean loadedCommands = false;
private String cachedHtml;
@GetMapping(endpoint = "/api/commands/")
public String getCommands(HttpServletRequest request, HttpServletResponse response)
{
if (!loadedCommands)
if (cachedHtml == null)
{
cachedHtml = buildSections();
}
String file = readFile(this.getClass().getResourceAsStream("/httpd/commands.html"));
file = file.replace("${commands}", cachedHtml);
return file;
}
private static String buildSections()
{
final SortedMap<String, List<Command>> commandMap = new TreeMap<>();
final CommandMap map = Bukkit.getCommandMap();
for (Command command : map.getKnownCommands().values())
{
String plugin = "Bukkit";
if (command instanceof PluginIdentifiableCommand)
if (command instanceof PluginIdentifiableCommand pic)
{
plugin = ((PluginIdentifiableCommand) command).getPlugin().getName();
plugin = pic.getPlugin().getName();
}
List<Command> pluginCommands = commandMap.computeIfAbsent(plugin, k -> new ArrayList<>());
if (!pluginCommands.contains(command))
{
@@ -44,68 +52,105 @@ public class CommandsEndpoint extends AbstractServlet
}
}
StringBuilder sb = new StringBuilder();
for (String key : commandMap.keySet())
{
commandMap.get(key).sort(Comparator.comparing(Command::getName));
StringBuilder rows = new StringBuilder();
for (Command command : commandMap.get(key))
List<Command> commands = commandMap.get(key);
commands.sort(Comparator.comparing(Command::getName));
sb.append(renderSection(key, commands));
}
return sb.toString();
}
private static String renderSection(String plugin, List<Command> commands)
{
String permission = command.getPermission();
if (command instanceof PlexCommand plexCmd)
StringBuilder cards = new StringBuilder();
for (Command c : commands)
{
cards.append(renderCard(c));
}
String name = escapeHtml(plugin);
return """
<details class="command-section group mt-3 first:mt-0" data-plugin="%s" open>
<summary class="group flex cursor-pointer list-none items-center justify-between gap-3 rounded-2xl px-2 py-3 transition-colors hover:bg-muted/40 [&::-webkit-details-marker]:hidden">
<span class="flex items-center gap-2.5 text-lg font-medium tracking-tight">
<svg class="size-4 text-muted-foreground transition-transform group-open:rotate-90" aria-hidden="true"><use href="#i-arrow-right"/></svg>
%s
</span>
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
%d %s
</span>
</summary>
<div class="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
%s
</div>
</details>
""".formatted(name, name, commands.size(), commands.size() == 1 ? "command" : "commands", cards);
}
private static String renderCard(Command c)
{
String name = escapeHtml(c.getName());
String aliases = c.getAliases() == null || c.getAliases().isEmpty() ? "" : String.join(", ", c.getAliases());
String description = c.getDescription() == null || c.getDescription().isBlank() ? "" : escapeHtml(c.getDescription());
String usage = cleanUsage(c.getUsage());
String permission = resolvePermission(c);
String aliasMarkup = aliases.isEmpty()
? ""
: "<span class=\"font-mono text-xs text-muted-foreground\">/ " + escapeHtml(aliases) + "</span>";
String descMarkup = description.isEmpty()
? "<p class=\"mt-2 text-sm text-muted-foreground/70 italic\">No description provided.</p>"
: "<p class=\"mt-2 text-sm text-muted-foreground\">" + description + "</p>";
String searchBlob = (name + " " + aliases + " " + description + " " + permission).toLowerCase();
return """
<article class="ring-card group flex flex-col rounded-2xl bg-card p-4 transition-colors hover:bg-secondary/50" data-search="%s">
<header class="flex flex-wrap items-baseline gap-2">
<code class="rounded-md bg-muted px-2 py-0.5 font-mono text-sm font-medium text-foreground">/%s</code>
%s
</header>
%s
<dl class="mt-3 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 border-t border-border/60 pt-3 font-mono text-[11px]">
<dt class="text-muted-foreground uppercase tracking-wider">usage</dt>
<dd class="text-foreground/80 break-all">%s</dd>
<dt class="text-muted-foreground uppercase tracking-wider">perm</dt>
<dd class="text-foreground/80 break-all">%s</dd>
</dl>
</article>
""".formatted(searchBlob, name, aliasMarkup, descMarkup, usage, permission);
}
private static String resolvePermission(Command c)
{
String permission = c.getPermission();
if (c instanceof PlexCommand plexCmd)
{
CommandPermissions perms = plexCmd.getClass().getAnnotation(CommandPermissions.class);
if (perms != null)
{
permission = (perms.permission().isBlank() ? "N/A" : perms.permission());
permission = perms.permission().isBlank() ? "N/A" : perms.permission();
}
}
if (permission == null || permission.isBlank()) return "N/A";
return escapeHtml(permission).replace(";", "<br>");
}
rows.append(createRow(command.getName(), command.getAliases(), command.getDescription(), command.getUsage(), permission));
}
list.append(createTable(key, rows.toString())).append("\n");
}
loadedCommands = true;
}
return commandsHTML(list.toString());
}
private String commandsHTML(String commandsList)
private static String cleanUsage(String usage)
{
String file = readFile(this.getClass().getResourceAsStream("/httpd/commands.html"));
file = file.replace("${commands}", commandsList);
return file;
if (usage == null || usage.isBlank()) return "Not provided";
String escaped = escapeHtml(usage);
return escaped.startsWith("/") ? escaped : "/" + escaped;
}
private String createTable(String pluginName, String commandRows)
private static String escapeHtml(String s)
{
return "<details id=\"" + pluginName + "\"><summary>" + pluginName + "</summary>\n"
+ "<table id=\"" + pluginName + "Table\" class=\"table table-striped table-bordered\">\n"
+ " <thead>\n <tr>\n <th scope=\"col\">Name (Aliases)</th>\n "
+ "<th scope=\"col\">Description</th>\n "
+ "<th scope=\"col\">Usage</th>\n "
+ "<th scope=\"col\">Permission</th>\n </tr>\n</thead>\n"
+ "<tbody>\n " + commandRows + "\n</tbody>\n</table>\n</details>";
}
private String createRow(String name, List<String> aliases, String description, String usage, String permission)
{
return " <tr>\n <th scope=\"row\">" + name
+ (aliases.isEmpty() || aliases.toString().equals("[]") ? "" : " (" + String.join(", ", aliases) + ")") + "</th>\n"
+ " <th scope=\"row\">" + description + "</th>\n"
+ " <th scope=\"row\"><code>" + cleanUsage(usage) + "</code></th>\n"
+ " <th scope=\"row\">" + (permission != null ? permission.replaceAll(";", "<br>") : "N/A") + "</th>\n </tr>";
}
private String cleanUsage(String usage)
{
usage = usage.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
if (usage.isBlank())
{
usage = "Not Provided";
}
return usage.startsWith("/") || usage.equals("Not Provided") ? usage : "/" + usage;
if (s == null) return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -0,0 +1,145 @@
package dev.plex.request.impl;
import dev.plex.HTTPDModule;
import dev.plex.Plex;
import dev.plex.cache.DataUtils;
import dev.plex.player.PlexPlayer;
import dev.plex.punishment.PunishmentManager.IndefiniteBan;
import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
public class IndefBansUIEndpoint extends AbstractServlet
{
@GetMapping(endpoint = "/indefbans/")
public String getBans(HttpServletRequest request, HttpServletResponse response)
{
String ipAddress = request.getRemoteAddr();
if (ipAddress == null)
{
return errorHTML("Cannot detect an IP address on this request.");
}
PlexPlayer viewer = DataUtils.getPlayerByIP(ipAddress);
if (viewer == null)
{
return errorHTML("This IP (" + escapeHtml(ipAddress) + ") is not linked to a known player.");
}
OfflinePlayer offline = Bukkit.getOfflinePlayer(viewer.getUuid());
if (!HTTPDModule.getPermissions().playerHas(null, offline, "plex.httpd.indefbans.access"))
{
return errorHTML("Your account does not have <span class=\"font-mono text-foreground\">plex.httpd.indefbans.access</span>.");
}
List<IndefiniteBan> bans = Plex.get().getPunishmentManager().getIndefiniteBans();
return listHTML(bans);
}
private String listHTML(List<IndefiniteBan> bans)
{
StringBuilder cards = new StringBuilder();
int totalUsers = 0, totalUuids = 0, totalIps = 0;
for (IndefiniteBan ban : bans)
{
totalUsers += ban.getUsernames().size();
totalUuids += ban.getUuids().size();
totalIps += ban.getIps().size();
cards.append(renderCard(ban));
}
if (cards.length() == 0)
{
cards.append("""
<div class="ring-card rounded-2xl bg-card p-10 text-center">
<svg class="mx-auto size-8 text-muted-foreground/60" aria-hidden="true"><use href="#i-check"/></svg>
<p class="mt-3 text-sm text-muted-foreground">No indefinite bans configured.</p>
</div>
""");
}
String file = readFile(this.getClass().getResourceAsStream("/httpd/indefbans_list.html"));
file = file.replace("${group_count}", String.valueOf(bans.size()));
file = file.replace("${total_users}", String.valueOf(totalUsers));
file = file.replace("${total_uuids}", String.valueOf(totalUuids));
file = file.replace("${total_ips}", String.valueOf(totalIps));
file = file.replace("${bans}", cards.toString());
return file;
}
private static String renderCard(IndefiniteBan ban)
{
StringBuilder chips = new StringBuilder();
for (String name : ban.getUsernames())
{
chips.append(chip("user", escapeHtml(name)));
}
for (UUID id : ban.getUuids())
{
chips.append(chip("uuid", id.toString()));
}
for (String ip : ban.getIps())
{
chips.append(chip("ip", escapeHtml(ip)));
}
String reason = (ban.getReason() == null || ban.getReason().isBlank())
? "<span class=\"italic text-muted-foreground/70\">No reason provided</span>"
: escapeHtml(ban.getReason());
int total = ban.getUsernames().size() + ban.getUuids().size() + ban.getIps().size();
return """
<article class="ring-card rounded-2xl bg-card p-5">
<header class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-sm">%s</p>
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">%d %s</span>
</header>
<div class="mt-4 flex flex-wrap gap-1.5">
%s
</div>
</article>
""".formatted(reason, total, total == 1 ? "entry" : "entries", chips);
}
private static String chip(String kind, String value)
{
String color = switch (kind)
{
case "user" -> "bg-muted text-foreground";
case "uuid" -> "bg-primary/10 text-primary";
case "ip" -> "bg-warning/10 text-warning";
default -> "bg-muted text-foreground";
};
String label = switch (kind)
{
case "user" -> "user";
case "uuid" -> "uuid";
case "ip" -> "ip";
default -> "";
};
return """
<span class="inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 font-mono text-xs %s">
<span class="text-[9px] uppercase tracking-wider opacity-60">%s</span>
<span>%s</span>
</span>
""".formatted(color, label, value);
}
private String errorHTML(String message)
{
String file = readFile(this.getClass().getResourceAsStream("/httpd/indefbans.html"));
file = file.replace("${MESSAGE}", message);
return file;
}
private static String escapeHtml(String s)
{
if (s == null) return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -4,31 +4,18 @@ import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.bukkit.Bukkit;
public class IndexEndpoint extends AbstractServlet
{
@GetMapping(endpoint = "//")
public String getIndex(HttpServletRequest request, HttpServletResponse response)
{
return indexHTML();
return readFile(this.getClass().getResourceAsStream("/httpd/index.html"));
}
@GetMapping(endpoint = "/api/")
public String getAPI(HttpServletRequest request, HttpServletResponse response)
{
return indexHTML();
}
private String indexHTML()
{
String file = readFile(this.getClass().getResourceAsStream("/httpd/index.html"));
String isAre = Bukkit.getOnlinePlayers().size() == 1 ? " is " : " are ";
String pluralOnline = Bukkit.getOnlinePlayers().size() == 1 ? " player " : " players ";
String pluralMax = Bukkit.getMaxPlayers() == 1 ? " player " : " players ";
file = file.replace("${is_are}", isAre);
file = file.replace("${server_online_players}", Bukkit.getOnlinePlayers().size() + pluralOnline);
file = file.replace("${server_total_players}", Bukkit.getMaxPlayers() + pluralMax);
return file;
return readFile(this.getClass().getResourceAsStream("/httpd/index.html"));
}
}
@@ -0,0 +1,106 @@
package dev.plex.request.impl;
import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Collection;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
public class PlayersEndpoint extends AbstractServlet
{
@GetMapping(endpoint = "/players/")
public String getPlayers(HttpServletRequest request, HttpServletResponse response)
{
Collection<? extends Player> players = Bukkit.getOnlinePlayers();
String cards = players.isEmpty() ? emptyState() : renderPlayerCards(players);
String file = readFile(this.getClass().getResourceAsStream("/httpd/players.html"));
file = file.replace("${player_count}", String.valueOf(players.size()));
file = file.replace("${player_max}", String.valueOf(Bukkit.getMaxPlayers()));
file = file.replace("${player_cards}", cards);
return file;
}
private static String renderPlayerCards(Collection<? extends Player> players)
{
StringBuilder sb = new StringBuilder();
for (Player p : players)
{
sb.append(renderCard(p));
}
return sb.toString();
}
private static String renderCard(Player p)
{
String uuid = p.getUniqueId().toString();
String name = escapeHtml(p.getName());
String gamemode = p.getGameMode().name().toLowerCase();
String world = escapeHtml(p.getWorld().getName());
int ping = safePing(p);
String pingColor = ping < 80 ? "text-success" : ping < 200 ? "text-warning" : "text-destructive";
String opChip = p.isOp()
? "<span class=\"inline-flex h-5 items-center rounded-full bg-primary/12 px-2 font-mono text-[10px] uppercase tracking-wider text-primary\">op</span>"
: "";
return """
<article class="ring-card group rounded-2xl bg-card p-4 transition-colors hover:bg-secondary/50" data-name="%s">
<div class="flex items-center gap-3">
<img class="size-12 rounded-xl bg-muted [image-rendering:pixelated]"
src="https://vzge.me/face/512/%s.png"
alt="" loading="lazy" width="48" height="48">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-medium">%s</span>
%s
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[11px] text-muted-foreground">
<span class="inline-flex h-5 items-center rounded-full bg-muted px-2">%s</span>
<span class="text-foreground/30">·</span>
<span>%s</span>
</div>
</div>
</div>
<div class="mt-3 flex items-center justify-between border-t border-border/60 pt-3 font-mono text-[11px]">
<span class="text-muted-foreground">ping</span>
<span class="tabular %s">%dms</span>
</div>
</article>
""".formatted(name, uuid, name, opChip, gamemode, world, pingColor, ping);
}
private static String emptyState()
{
return """
<div class="ring-card col-span-full rounded-2xl bg-card p-10 text-center">
<svg class="mx-auto size-8 text-muted-foreground/60" aria-hidden="true"><use href="#i-users"/></svg>
<p class="mt-3 text-sm text-muted-foreground">No players online right now.</p>
</div>
""";
}
private static int safePing(Player p)
{
try
{
return p.getPing();
}
catch (Throwable t)
{
return 0;
}
}
private static String escapeHtml(String s)
{
if (s == null) return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -0,0 +1,212 @@
package dev.plex.request.impl;
import dev.plex.HTTPDModule;
import dev.plex.Plex;
import dev.plex.cache.DataUtils;
import dev.plex.player.PlexPlayer;
import dev.plex.punishment.Punishment;
import dev.plex.punishment.PunishmentType;
import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
public class PunishmentsUIEndpoint extends AbstractServlet
{
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z");
@GetMapping(endpoint = "/punishments/")
public String getPunishments(HttpServletRequest request, HttpServletResponse response)
{
String path = request.getPathInfo();
if (path == null || path.equals("/"))
{
return readFile(this.getClass().getResourceAsStream("/httpd/punishments.html"));
}
String query = path.replace("/", "").trim();
if (query.isEmpty())
{
return readFile(this.getClass().getResourceAsStream("/httpd/punishments.html"));
}
PlexPlayer punished = lookupPlayer(query);
if (punished == null)
{
return errorHTML("No player found matching <span class=\"font-mono text-foreground\">" + escapeHtml(query) + "</span>.");
}
List<Punishment> punishments = punished.getPunishments();
if (punishments == null || punishments.isEmpty())
{
return goodHTML(escapeHtml(punished.getName()) + " has no punishments on record.");
}
boolean showIps = canViewIps(request.getRemoteAddr());
return resultsHTML(punished, punishments, showIps);
}
private static PlexPlayer lookupPlayer(String query)
{
try
{
return DataUtils.getPlayer(UUID.fromString(query));
}
catch (IllegalArgumentException ignored)
{
return DataUtils.getPlayer(query);
}
}
private static boolean canViewIps(String requesterIp)
{
if (requesterIp == null) return false;
PlexPlayer viewer = DataUtils.getPlayerByIP(requesterIp);
if (viewer == null) return false;
OfflinePlayer offline = Bukkit.getOfflinePlayer(viewer.getUuid());
return HTTPDModule.getPermissions().playerHas(null, offline, "plex.httpd.punishments.access");
}
private String resultsHTML(PlexPlayer player, List<Punishment> punishments, boolean showIps)
{
StringBuilder cards = new StringBuilder();
for (Punishment p : punishments)
{
cards.append(renderCard(p, showIps));
}
String file = readFile(this.getClass().getResourceAsStream("/httpd/punishments_results.html"));
file = file.replace("${player_name}", escapeHtml(player.getName()));
file = file.replace("${player_uuid}", player.getUuid().toString());
file = file.replace("${punishment_count}", String.valueOf(punishments.size()));
file = file.replace("${punishment_label}", punishments.size() == 1 ? "punishment" : "punishments");
file = file.replace("${punishments}", cards.toString());
return file;
}
private static String renderCard(Punishment p, boolean showIps)
{
PunishmentType type = p.getType();
String typeName = type == null ? "UNKNOWN" : type.name();
String accent = accentFor(type);
String rawReason = (p.getReason() == null || p.getReason().isBlank()) ? "" : p.getReason();
String reason = rawReason.isEmpty() ? "<span class=\"italic text-muted-foreground/70\">No reason provided</span>" : escapeHtml(rawReason);
String punisher = resolveName(p.getPunisher());
String endDate = p.getEndDate() == null ? "permanent" : escapeHtml(formatDate(p.getEndDate()));
boolean isBan = type == PunishmentType.BAN || type == PunishmentType.TEMPBAN;
String status = "";
String statusChip = "";
if (isBan)
{
if (p.isActive())
{
status = "active";
statusChip = "<span class=\"inline-flex h-5 items-center rounded-full bg-destructive/10 px-2 font-mono text-[10px] uppercase tracking-wider text-destructive\">active</span>";
}
else
{
status = "expired";
statusChip = "<span class=\"inline-flex h-5 items-center rounded-full bg-muted px-2 font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">expired</span>";
}
}
String ipRow = "";
String ipBlob = "";
if (showIps && p.getIp() != null && !p.getIp().isBlank())
{
ipBlob = p.getIp();
ipRow = """
<dt class="text-muted-foreground uppercase tracking-wider">IP</dt>
<dd class="text-foreground/80 break-all">%s</dd>
""".formatted(escapeHtml(p.getIp()));
}
String searchBlob = escapeHtml((typeName + " " + rawReason + " " + punisher + " " + status + " " + ipBlob).toLowerCase());
return """
<article class="ring-card rounded-2xl bg-card p-5" data-search="%s" data-type="%s" data-status="%s">
<header class="flex flex-wrap items-center gap-2">
<span class="inline-flex h-6 items-center rounded-full bg-%s/10 px-2.5 font-mono text-xs font-medium uppercase tracking-wider text-%s">%s</span>
%s
</header>
<p class="mt-3 text-sm">%s</p>
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 border-t border-border/60 pt-3 font-mono text-[11px]">
<dt class="text-muted-foreground uppercase tracking-wider">Punisher</dt>
<dd class="text-foreground/80">%s</dd>
<dt class="text-muted-foreground uppercase tracking-wider">Expires</dt>
<dd class="text-foreground/80">%s</dd>
%s
</dl>
</article>
""".formatted(searchBlob, typeName, status, accent, accent, typeName, statusChip, reason, escapeHtml(punisher), endDate, ipRow);
}
private static String accentFor(PunishmentType type)
{
if (type == null) return "muted-foreground";
return switch (type)
{
case BAN, SMITE -> "destructive";
case TEMPBAN, MUTE -> "warning";
case KICK, FREEZE -> "primary";
};
}
private static String resolveName(UUID uuid)
{
if (uuid == null) return "CONSOLE";
try
{
String name = Plex.get().getSqlPlayerData().getNameByUUID(uuid);
if (name != null && !name.isBlank()) return name;
}
catch (Throwable ignored)
{
}
return uuid.toString();
}
private static String formatDate(ZonedDateTime date)
{
try
{
return DATE_FMT.format(date);
}
catch (Throwable t)
{
return date.toString();
}
}
private String errorHTML(String message)
{
String file = readFile(this.getClass().getResourceAsStream("/httpd/punishments_error.html"));
file = file.replace("${MESSAGE}", message);
return file;
}
private String goodHTML(String message)
{
String file = readFile(this.getClass().getResourceAsStream("/httpd/punishments_good.html"));
file = file.replace("${MESSAGE}", message);
return file;
}
private static String escapeHtml(String s)
{
if (s == null) return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -77,15 +77,25 @@ public class SchematicDownloadEndpoint extends AbstractServlet
{
return null;
}
List<File> entries = listFilesForFolder(worldeditFolder);
StringBuilder sb = new StringBuilder();
for (File worldeditFile : listFilesForFolder(worldeditFolder))
if (entries.isEmpty())
{
String fixedPath = worldeditFile.getPath().replace("plugins/FastAsyncWorldEdit/schematics/", "");
fixedPath = fixedPath.replace("plugins/WorldEdit/schematics/", "");
String sanitizedName = fixedPath.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
sb.append(" <tr>\n" + " <th scope=\"row\">\n <a href=\"").append(fixedPath).append("\" download>")
.append(sanitizedName).append("</a>\n </th>\n").append(" <td>\n ")
.append(formattedSize(worldeditFile.length())).append("\n </td>\n").append(" </tr>\n");
sb.append("<tr><td colspan=\"3\" class=\"px-4 py-8 text-center text-sm text-muted-foreground\">No schematics yet.</td></tr>");
}
for (File worldeditFile : entries)
{
String fixedPath = worldeditFile.getPath()
.replace("plugins/FastAsyncWorldEdit/schematics/", "")
.replace("plugins/WorldEdit/schematics/", "");
String sanitizedName = fixedPath.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
String size = formattedSize(worldeditFile.length());
sb.append("<tr data-name=\"").append(sanitizedName).append("\" class=\"transition-colors hover:bg-muted/40\">\n")
.append(" <td class=\"px-4 py-2.5\"><a class=\"font-mono text-foreground hover:text-primary\" href=\"")
.append(sanitizedName).append("\" download>").append(sanitizedName).append("</a></td>\n")
.append(" <td class=\"px-4 py-2.5 text-right font-mono text-xs text-muted-foreground tabular\">").append(size).append("</td>\n")
.append(" <td class=\"pr-3\"><a href=\"").append(sanitizedName).append("\" download class=\"inline-flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\" aria-label=\"Download\"><svg class=\"size-3.5\" aria-hidden=\"true\"><use href=\"#i-download\"/></svg></a></td>\n")
.append("</tr>\n");
}
file = file.replace("${schematics}", sb.toString());
files.clear();
@@ -0,0 +1,140 @@
package dev.plex.request.impl;
import com.google.gson.GsonBuilder;
import dev.plex.Plex;
import dev.plex.request.AbstractServlet;
import dev.plex.request.GetMapping;
import dev.plex.request.MappingHeaders;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.lang.management.ManagementFactory;
import java.util.LinkedHashMap;
import java.util.Map;
import org.bukkit.Bukkit;
import org.bukkit.World;
public class StatsEndpoint extends AbstractServlet
{
private static volatile int cachedChunks = 0;
private static volatile int cachedEntities = 0;
private static volatile boolean schedulerStarted = false;
public StatsEndpoint()
{
super();
startWorldSnapshotTask();
}
private static synchronized void startWorldSnapshotTask()
{
if (schedulerStarted) return;
try
{
Bukkit.getScheduler().runTaskTimer(Plex.get(), () ->
{
int chunks = 0;
int entities = 0;
for (World world : Bukkit.getWorlds())
{
try
{
chunks += world.getLoadedChunks().length;
entities += world.getEntities().size();
}
catch (Throwable ignored)
{
}
}
cachedChunks = chunks;
cachedEntities = entities;
}, 0L, 40L);
schedulerStarted = true;
}
catch (Throwable ignored)
{
}
}
@GetMapping(endpoint = "/api/stats/")
@MappingHeaders(headers = {"content-type;application/json; charset=utf-8", "cache-control;no-store"})
public String getStats(HttpServletRequest request, HttpServletResponse response)
{
Map<String, Object> root = new LinkedHashMap<>();
Map<String, Object> server = new LinkedHashMap<>();
server.put("version", safeServerVersion());
server.put("uptime", ManagementFactory.getRuntimeMXBean().getUptime());
server.put("tps", safeTps());
root.put("server", server);
com.sun.management.OperatingSystemMXBean os =
(com.sun.management.OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
Map<String, Object> cpu = new LinkedHashMap<>();
cpu.put("process", clamp01(os.getProcessCpuLoad()));
cpu.put("system", clamp01(os.getCpuLoad()));
cpu.put("cores", os.getAvailableProcessors());
cpu.put("loadAverage", os.getSystemLoadAverage());
root.put("cpu", cpu);
Runtime rt = Runtime.getRuntime();
long max = rt.maxMemory();
long total = rt.totalMemory();
long free = rt.freeMemory();
long used = total - free;
Map<String, Object> memory = new LinkedHashMap<>();
memory.put("used", used);
memory.put("total", total);
memory.put("max", max);
root.put("memory", memory);
Map<String, Object> players = new LinkedHashMap<>();
players.put("online", Bukkit.getOnlinePlayers().size());
players.put("max", Bukkit.getMaxPlayers());
root.put("players", players);
Map<String, Object> world = new LinkedHashMap<>();
world.put("loadedChunks", cachedChunks);
world.put("entities", cachedEntities);
world.put("worlds", Bukkit.getWorlds().size());
root.put("world", world);
Map<String, Object> plugins = new LinkedHashMap<>();
plugins.put("active", Bukkit.getPluginManager().getPlugins().length);
root.put("plugins", plugins);
return new GsonBuilder().serializeNulls().create().toJson(root);
}
private static double clamp01(double v)
{
if (Double.isNaN(v) || v < 0) return 0d;
if (v > 1) return 1d;
return v;
}
private static double[] safeTps()
{
try
{
return Bukkit.getTPS();
}
catch (Throwable t)
{
return new double[]{20d, 20d, 20d};
}
}
private static String safeServerVersion()
{
try
{
return Bukkit.getMinecraftVersion();
}
catch (Throwable t)
{
return Bukkit.getBukkitVersion();
}
}
}
@@ -0,0 +1,178 @@
(function () {
const POLL_MS = 3000;
const SPARK_MAX = 60;
const tpsHistory = [];
const fmt = {
pct(n) {
if (!isFinite(n) || n === null) return '—';
return (n * 100).toFixed(1) + '%';
},
bytes(b) {
if (b == null || !isFinite(b)) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let n = b, i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + units[i];
},
bytesValue(b) {
if (b == null || !isFinite(b)) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let n = b, i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return i === 0 ? n.toFixed(0) : n.toFixed(1);
},
bytesUnit(b) {
if (b == null || !isFinite(b)) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let n = b, i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return units[i];
},
tps(n) {
if (!isFinite(n)) return '—';
return Math.min(n, 20).toFixed(2);
},
int(n) {
if (n == null || !isFinite(n)) return '—';
return Math.round(n).toLocaleString();
},
duration(ms) {
if (!ms || !isFinite(ms)) return '—';
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m ${sec}s`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
};
function setText(selector, value) {
document.querySelectorAll(selector).forEach(el => {
if (el.textContent !== value) {
el.textContent = value;
el.classList.remove('tick');
void el.offsetWidth;
el.classList.add('tick');
}
});
}
function setWidth(selector, percent) {
document.querySelectorAll(selector).forEach(el => {
el.style.width = Math.max(0, Math.min(100, percent)).toFixed(1) + '%';
});
}
function setBarColor(selector, percent) {
document.querySelectorAll(selector).forEach(el => {
el.classList.remove('bg-primary', 'bg-warning', 'bg-destructive');
const cls = percent < 70 ? 'bg-primary' : (percent < 90 ? 'bg-warning' : 'bg-destructive');
el.classList.add(cls);
});
}
function setTpsBadge(tps) {
document.querySelectorAll('[data-tps-state]').forEach(el => {
el.classList.remove('text-success', 'text-warning', 'text-destructive');
el.classList.add(tps >= 19.5 ? 'text-success' : tps >= 18 ? 'text-warning' : 'text-destructive');
});
}
function renderSparkline(history) {
const svg = document.querySelector('[data-spark="tps"]');
if (!svg) return;
const w = svg.viewBox.baseVal.width || 600;
const h = svg.viewBox.baseVal.height || 80;
const pad = 4;
const max = 20;
const min = 15;
if (history.length < 2) return;
const stepX = (w - pad * 2) / (SPARK_MAX - 1);
const xs = history.slice(-SPARK_MAX);
const offset = SPARK_MAX - xs.length;
const points = xs.map((v, i) => {
const x = pad + (i + offset) * stepX;
const cv = Math.max(min, Math.min(max, v));
const y = pad + (h - pad * 2) * (1 - (cv - min) / (max - min));
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const line = svg.querySelector('[data-spark-line]');
if (line) line.setAttribute('points', points.join(' '));
const area = svg.querySelector('[data-spark-area]');
if (area) {
const first = points[0].split(',');
const last = points[points.length - 1].split(',');
area.setAttribute('points',
`${first[0]},${h - pad} ${points.join(' ')} ${last[0]},${h - pad}`);
}
}
async function refresh() {
try {
const r = await fetch('/api/stats/', {cache: 'no-store'});
if (!r.ok) throw new Error('HTTP ' + r.status);
const s = await r.json();
paint(s);
setStatus(true);
} catch (e) {
setStatus(false);
}
}
function setStatus(ok) {
document.querySelectorAll('[data-status="text"]').forEach(el => {
el.textContent = ok ? 'online' : 'offline';
});
document.querySelectorAll('.status-dot').forEach(el => {
el.classList.remove('bg-success', 'bg-destructive');
el.classList.add(ok ? 'bg-success' : 'bg-destructive');
});
}
function paint(s) {
setText('[data-stat="players-online"]', String(s.players.online));
setText('[data-stat="players-max"]', String(s.players.max));
const pPercent = s.players.max > 0 ? (s.players.online / s.players.max) * 100 : 0;
setWidth('[data-stat="players-bar"]', pPercent);
setText('[data-stat="cpu-process-value"]', fmt.pct(s.cpu.process));
setText('[data-stat="cpu-system-value"]', fmt.pct(s.cpu.system));
setText('[data-stat="cpu-cores"]', String(s.cpu.cores));
const cpuPercent = (s.cpu.process || 0) * 100;
setWidth('[data-stat="cpu-bar"]', cpuPercent);
setBarColor('[data-stat="cpu-bar"]', cpuPercent);
setText('[data-stat="mem-value"]', fmt.bytesValue(s.memory.used));
setText('[data-stat="mem-unit"]', fmt.bytesUnit(s.memory.used));
setText('[data-stat="mem-max"]', fmt.bytes(s.memory.max));
const memPercent = (s.memory.used / s.memory.max) * 100;
setText('[data-stat="mem-percent"]', memPercent.toFixed(1) + '%');
setWidth('[data-stat="mem-bar"]', memPercent);
setBarColor('[data-stat="mem-bar"]', memPercent);
const tps1 = s.server.tps[0];
setText('[data-stat="tps-1m"]', fmt.tps(tps1));
setText('[data-stat="tps-5m"]', fmt.tps(s.server.tps[1]));
setText('[data-stat="tps-15m"]', fmt.tps(s.server.tps[2]));
setTpsBadge(tps1);
tpsHistory.push(tps1);
if (tpsHistory.length > SPARK_MAX) tpsHistory.shift();
renderSparkline(tpsHistory);
setText('[data-stat="uptime"]', fmt.duration(s.server.uptime));
setText('[data-stat="version"]', s.server.version);
setText('[data-stat="chunks"]', fmt.int(s.world.loadedChunks));
setText('[data-stat="entities"]', fmt.int(s.world.entities));
setText('[data-stat="worlds"]', fmt.int(s.world.worlds));
setText('[data-stat="plugins"]', fmt.int(s.plugins.active));
}
refresh();
setInterval(refresh, POLL_MS);
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+61 -9
View File
@@ -1,12 +1,64 @@
Commands
COMMANDS
<style>
summary {
font-size: 24px;
padding: 16px;
}
</style>
<h2>Commands List</h2>
<h5>A list of commands is below.</h5>
<br><br>
<section class="rise">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Commands</h1>
</section>
<section class="rise rise-1 mt-6 flex flex-wrap items-center gap-3">
<label class="ring-card relative flex h-10 flex-1 items-center gap-2 rounded-full bg-card px-3 transition-colors focus-within:bg-background focus-within:ring-2 focus-within:ring-ring/30">
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-search"/></svg>
<input id="command-filter"
type="text"
placeholder="Filter commands, aliases, permissions..."
class="h-full flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autocomplete="off">
</label>
<button type="button" id="command-toggle"
class="ring-card inline-flex h-10 items-center gap-1.5 rounded-full bg-card px-4 text-sm font-medium transition-colors hover:bg-secondary"
data-state="open">
Collapse all
</button>
</section>
<p class="rise rise-1 mt-2 hidden font-mono text-[11px] text-destructive" id="command-empty">No commands match that filter.</p>
<section class="rise rise-2 mt-2">
${commands}
</section>
<script>
(function () {
const input = document.getElementById('command-filter');
const toggle = document.getElementById('command-toggle');
const empty = document.getElementById('command-empty');
const sections = Array.from(document.querySelectorAll('.command-section'));
if (input) {
input.addEventListener('input', () => {
const q = input.value.toLowerCase().trim();
let anyShown = false;
sections.forEach(section => {
const cards = Array.from(section.querySelectorAll('[data-search]'));
let shownInSection = 0;
cards.forEach(c => {
const match = !q || (c.getAttribute('data-search') || '').includes(q);
c.style.display = match ? '' : 'none';
if (match) shownInSection++;
});
section.style.display = shownInSection ? '' : 'none';
if (q) section.open = shownInSection > 0;
if (shownInSection) anyShown = true;
});
if (empty) empty.classList.toggle('hidden', anyShown || !q);
});
}
if (toggle) {
toggle.addEventListener('click', () => {
const willOpen = toggle.dataset.state !== 'open';
sections.forEach(s => { s.open = willOpen; });
toggle.dataset.state = willOpen ? 'open' : 'closed';
toggle.textContent = willOpen ? 'Collapse all' : 'Expand all';
});
}
})();
</script>
+10 -2
View File
@@ -1,4 +1,12 @@
Indefinite Bans
INDEFBANS
<h2>Indefinite Bans</h2>
<h5 class="alert alert-danger mb-3 w-auto p-3" role="alert"><b>Error:</b> ${MESSAGE}</h5>
<section class="rise">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Indefinite bans</h1>
</section>
<div class="rise rise-1 ring-card mt-6 flex items-start gap-3 rounded-2xl bg-card p-4">
<svg class="mt-0.5 size-5 shrink-0 text-destructive" aria-hidden="true"><use href="#i-alert"/></svg>
<div class="min-w-0">
<p class="text-sm text-muted-foreground">${MESSAGE}</p>
</div>
</div>
@@ -0,0 +1,41 @@
Indefinite Bans
INDEFBANS
<section class="rise flex flex-wrap items-end justify-between gap-3">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Indefinite bans</h1>
<div class="flex items-center gap-4 font-mono text-[11px] text-muted-foreground tabular">
<span><span class="text-foreground">${group_count}</span> groups</span>
<span><span class="text-foreground">${total_users}</span> users</span>
<span><span class="text-foreground">${total_uuids}</span> uuids</span>
<span><span class="text-foreground">${total_ips}</span> ips</span>
</div>
</section>
<section class="rise rise-1 mt-6">
<label class="ring-card relative flex h-10 w-full items-center gap-2 rounded-full bg-card px-3 transition-colors focus-within:bg-background focus-within:ring-2 focus-within:ring-ring/30 sm:max-w-md">
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-search"/></svg>
<input id="indef-filter"
type="text"
placeholder="Filter by name, UUID, or IP..."
class="h-full flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autocomplete="off">
</label>
</section>
<section class="rise rise-2 mt-4 grid gap-3 md:grid-cols-2" id="indef-grid">
${bans}
</section>
<script>
(function () {
const input = document.getElementById('indef-filter');
if (!input) return;
const cards = Array.from(document.querySelectorAll('#indef-grid > article'));
input.addEventListener('input', () => {
const q = input.value.toLowerCase().trim();
cards.forEach(card => {
const text = card.textContent.toLowerCase();
card.style.display = (!q || text.includes(q)) ? '' : 'none';
});
});
})();
</script>
+147 -4
View File
@@ -1,5 +1,148 @@
Home
Overview
HOME
<h2>Welcome to the Plex HTTPD!</h2>
<h4>Use the sidebar to navigate the available pages.</h4>
<h4><br>There ${is_are} currently ${server_online_players} online out of ${server_total_players} total.</h4>
<section class="rise flex flex-wrap items-end justify-between gap-3">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Overview</h1>
<span class="font-mono text-xs text-muted-foreground">
Minecraft version <span data-stat="version"></span>
</span>
</section>
<section class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<article class="rise rise-1 ring-card relative overflow-hidden rounded-2xl bg-card p-5">
<div class="flex items-center justify-between">
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">Players</span>
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-users"/></svg>
</div>
<div class="mt-4 flex items-baseline gap-2">
<span data-stat="players-online" class="tabular text-4xl font-medium tracking-tight"></span>
<span class="font-mono text-sm text-muted-foreground">/ <span data-stat="players-max" class="tabular"></span></span>
</div>
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted">
<div data-stat="players-bar" class="h-full rounded-full bg-primary transition-[width] duration-500" style="width:0"></div>
</div>
<div class="mt-3 flex items-center justify-between font-mono text-[11px] text-muted-foreground">
<span>online</span>
<a href="/players/" class="inline-flex items-center gap-1 text-foreground/80 hover:text-foreground">
view list <svg class="size-3" aria-hidden="true"><use href="#i-arrow-right"/></svg>
</a>
</div>
</article>
<article class="rise rise-2 ring-card relative overflow-hidden rounded-2xl bg-card p-5">
<div class="flex items-center justify-between">
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">CPU</span>
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-chip"/></svg>
</div>
<div class="mt-4 flex items-baseline gap-1.5">
<span data-stat="cpu-process-value" class="tabular text-4xl font-medium tracking-tight"></span>
</div>
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted">
<div data-stat="cpu-bar" class="h-full rounded-full bg-primary transition-[width] duration-500" style="width:0"></div>
</div>
<div class="mt-3 flex items-center justify-between font-mono text-[11px] text-muted-foreground">
<span>process · <span data-stat="cpu-cores" class="tabular text-foreground/80"></span> cores</span>
<span>sys <span data-stat="cpu-system-value" class="tabular text-foreground/80"></span></span>
</div>
</article>
<article class="rise rise-3 ring-card relative overflow-hidden rounded-2xl bg-card p-5">
<div class="flex items-center justify-between">
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">Memory</span>
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-database"/></svg>
</div>
<div class="mt-4 flex items-baseline gap-1.5">
<span data-stat="mem-value" class="tabular text-4xl font-medium tracking-tight"></span>
<span data-stat="mem-unit" class="font-mono text-sm text-muted-foreground"></span>
</div>
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted">
<div data-stat="mem-bar" class="h-full rounded-full bg-primary transition-[width] duration-500" style="width:0"></div>
</div>
<div class="mt-3 flex items-center justify-between font-mono text-[11px] text-muted-foreground">
<span>heap · <span data-stat="mem-percent" class="tabular text-foreground/80"></span></span>
<span>max <span data-stat="mem-max" class="tabular text-foreground/80"></span></span>
</div>
</article>
<article class="rise rise-4 ring-card relative overflow-hidden rounded-2xl bg-card p-5">
<div class="flex items-center justify-between">
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">Ticks per second</span>
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-chart"/></svg>
</div>
<div class="mt-4 flex items-baseline gap-1.5">
<span data-stat="tps-1m" data-tps-state class="tabular text-4xl font-medium tracking-tight text-success"></span>
<span class="font-mono text-sm text-muted-foreground">/ 20.00</span>
</div>
<svg data-spark="tps" viewBox="0 0 600 60" preserveAspectRatio="none" class="mt-3 h-12 w-full overflow-visible">
<defs>
<linearGradient id="spark-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="currentColor" stop-opacity="0.18"/>
<stop offset="100%" stop-color="currentColor" stop-opacity="0"/>
</linearGradient>
</defs>
<polygon data-spark-area class="text-primary" fill="url(#spark-fill)" points=""/>
<polyline data-spark-line fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" class="text-primary" points=""/>
</svg>
<div class="mt-1 flex items-center justify-between font-mono text-[11px] text-muted-foreground">
<span>5m <span data-stat="tps-5m" class="tabular text-foreground/80"></span></span>
<span>15m <span data-stat="tps-15m" class="tabular text-foreground/80"></span></span>
</div>
</article>
</section>
<section class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<article class="rise rise-5 ring-card rounded-2xl bg-card p-5">
<div class="flex items-center justify-between">
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">Uptime</span>
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-clock"/></svg>
</div>
<div class="mt-3 font-mono text-2xl tracking-tight">
<span data-stat="uptime"></span>
</div>
</article>
<article class="rise rise-5 ring-card rounded-2xl bg-card p-5">
<div class="flex items-center justify-between">
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">World</span>
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-package"/></svg>
</div>
<dl class="mt-3 grid grid-cols-3 gap-2 font-mono text-sm">
<div>
<dt class="text-[10px] uppercase tracking-wider text-muted-foreground">Worlds</dt>
<dd data-stat="worlds" class="mt-1 tabular text-lg text-foreground"></dd>
</div>
<div>
<dt class="text-[10px] uppercase tracking-wider text-muted-foreground">Chunks</dt>
<dd data-stat="chunks" class="mt-1 tabular text-lg text-foreground"></dd>
</div>
<div>
<dt class="text-[10px] uppercase tracking-wider text-muted-foreground">Entities</dt>
<dd data-stat="entities" class="mt-1 tabular text-lg text-foreground"></dd>
</div>
</dl>
</article>
<article class="rise rise-6 ring-card rounded-2xl bg-card p-5">
<div class="flex items-center justify-between">
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">Plugins</span>
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-code"/></svg>
</div>
<div class="mt-3 flex items-baseline gap-2">
<span data-stat="plugins" class="tabular text-2xl font-medium tracking-tight"></span>
<span class="text-sm text-muted-foreground">active</span>
</div>
<div class="mt-3 flex gap-2">
<a href="/api/commands/" class="inline-flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground">
<svg class="size-3" aria-hidden="true"><use href="#i-arrow-right"/></svg> commands
</a>
<a href="/api/schematics/download/" class="inline-flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground">
<svg class="size-3" aria-hidden="true"><use href="#i-arrow-right"/></svg> schematics
</a>
</div>
</article>
</section>
<script src="/assets/dashboard.js" defer></script>
+43
View File
@@ -0,0 +1,43 @@
Players
PLAYERS
<section class="rise flex flex-wrap items-end justify-between gap-3">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Players</h1>
<span class="font-mono text-xs text-muted-foreground tabular">
<span class="text-foreground">${player_count}</span> / <span>${player_max}</span> online
</span>
</section>
<section class="rise rise-1 mt-6 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<label class="ring-card relative flex h-10 flex-1 items-center gap-2 rounded-full bg-card px-3 transition-colors focus-within:bg-background focus-within:ring-2 focus-within:ring-ring/30">
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-search"/></svg>
<input id="player-filter"
type="text"
placeholder="Filter by name..."
class="h-full flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autocomplete="off">
</label>
<button type="button" onclick="location.reload()"
class="ring-card inline-flex h-10 items-center justify-center gap-1.5 rounded-full bg-card px-4 text-sm font-medium transition-colors hover:bg-secondary">
<svg class="size-4" aria-hidden="true"><use href="#i-refresh"/></svg>
Refresh
</button>
</section>
<section class="rise rise-2 mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" id="players-grid">
${player_cards}
</section>
<script>
(function () {
const input = document.getElementById('player-filter');
if (!input) return;
const cards = Array.from(document.querySelectorAll('#players-grid [data-name]'));
input.addEventListener('input', () => {
const q = input.value.toLowerCase().trim();
cards.forEach(c => {
const n = (c.getAttribute('data-name') || '').toLowerCase();
c.style.display = (!q || n.includes(q)) ? '' : 'none';
});
});
})();
</script>
+25 -16
View File
@@ -1,23 +1,32 @@
Punishments
PUNISHMENTS
<h2>Punishment Search</h2>
<label for="uuid"><h5>Enter the UUID or username of the player you want to lookup</h5></label>
<div class="input-group mb-3 w-75 p-3">
<input id="uuid" type="text" autocomplete="off" autofocus class="form-control">
<button class="btn btn-outline-primary" type="submit"
onclick="redirect();">Submit
<section class="rise">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Punishments</h1>
</section>
<section class="rise rise-1 mt-6 max-w-2xl">
<form onsubmit="event.preventDefault(); redirect();" class="flex flex-col gap-3 sm:flex-row">
<label class="ring-card relative flex h-10 flex-1 items-center gap-2 rounded-full bg-card px-3 transition-colors focus-within:bg-background focus-within:ring-2 focus-within:ring-ring/30">
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-search"/></svg>
<input id="uuid"
type="text"
autocomplete="off"
autofocus
placeholder="UUID or username"
class="h-full flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground">
</label>
<button type="submit"
class="inline-flex h-10 items-center justify-center gap-1.5 rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/85">
Search
<svg class="size-3.5" aria-hidden="true"><use href="#i-arrow-right"/></svg>
</button>
</div>
</form>
</section>
<script>
function redirect() {
const url = document.getElementById('uuid').value;
window.location = "/api/punishments/" + url
const value = document.getElementById('uuid').value.trim();
if (!value) return;
window.location = '/punishments/' + encodeURIComponent(value);
}
</script>
<script>
document.getElementById('uuid').addEventListener('keypress', function (event) {
if (event.keyCode === 13) {
redirect();
}
});
</script>
@@ -1,4 +1,18 @@
Punishments
PUNISHMENTS
<h2>Punishment Search</h2>
<h5 class="alert alert-danger mb-3 w-auto p-3" role="alert"><b>Error:</b> ${MESSAGE}</h5>
<section class="rise">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Punishments</h1>
</section>
<div class="rise rise-1 ring-card mt-6 flex items-start gap-3 rounded-2xl bg-card p-4">
<svg class="mt-0.5 size-5 shrink-0 text-destructive" aria-hidden="true"><use href="#i-alert"/></svg>
<p class="text-sm text-muted-foreground">${MESSAGE}</p>
</div>
<div class="rise rise-2 mt-4">
<a href="/punishments/"
class="inline-flex h-9 items-center gap-1.5 rounded-full bg-muted px-4 text-sm font-medium transition-colors hover:bg-secondary">
<svg class="size-3.5" aria-hidden="true"><use href="#i-search"/></svg>
New search
</a>
</div>
+16 -2
View File
@@ -1,4 +1,18 @@
Punishments
PUNISHMENTS
<h2>Punishment Search</h2>
<h5 class="alert alert-success mb-3 w-auto p-3" role="alert">${MESSAGE}</h5>
<section class="rise">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Punishments</h1>
</section>
<div class="rise rise-1 ring-card mt-6 flex items-start gap-3 rounded-2xl bg-card p-4">
<svg class="mt-0.5 size-5 shrink-0 text-success" aria-hidden="true"><use href="#i-check"/></svg>
<p class="text-sm text-muted-foreground">${MESSAGE}</p>
</div>
<div class="rise rise-2 mt-4">
<a href="/punishments/"
class="inline-flex h-9 items-center gap-1.5 rounded-full bg-muted px-4 text-sm font-medium transition-colors hover:bg-secondary">
<svg class="size-3.5" aria-hidden="true"><use href="#i-search"/></svg>
New search
</a>
</div>
@@ -0,0 +1,104 @@
Punishments
PUNISHMENTS
<section class="rise flex flex-wrap items-end justify-between gap-3">
<div class="flex items-center gap-3">
<img class="size-12 rounded-xl bg-muted [image-rendering:pixelated]"
src="https://vzge.me/face/512/${player_uuid}.png"
alt="" loading="lazy" width="48" height="48">
<div>
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">${player_name}</h1>
<p class="mt-1 font-mono text-[11px] text-muted-foreground break-all">${player_uuid}</p>
</div>
</div>
<span class="font-mono text-xs text-muted-foreground tabular">
<span class="text-foreground">${punishment_count}</span> ${punishment_label}
</span>
</section>
<section class="rise rise-1 mt-4 flex flex-wrap items-center gap-3">
<label class="ring-card relative flex h-10 flex-1 items-center gap-2 rounded-full bg-card px-3 transition-colors focus-within:bg-background focus-within:ring-2 focus-within:ring-ring/30 sm:max-w-md">
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-search"/></svg>
<input id="punish-filter"
type="text"
placeholder="Filter by reason, punisher, type, IP..."
class="h-full flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autocomplete="off">
</label>
<a href="/punishments/"
class="inline-flex h-9 items-center gap-1.5 rounded-full bg-muted px-4 text-sm font-medium transition-colors hover:bg-secondary">
<svg class="size-3.5" aria-hidden="true"><use href="#i-search"/></svg>
New search
</a>
</section>
<section class="rise rise-1 mt-3 flex flex-wrap items-center gap-1.5" id="punish-chips"></section>
<section class="rise rise-2 mt-4 grid gap-3 md:grid-cols-2" id="punish-grid">
${punishments}
</section>
<p id="punish-empty" class="rise rise-2 mt-4 hidden font-mono text-[11px] text-muted-foreground">No punishments match those filters.</p>
<script>
(function () {
const input = document.getElementById('punish-filter');
const chips = document.getElementById('punish-chips');
const empty = document.getElementById('punish-empty');
const cards = Array.from(document.querySelectorAll('#punish-grid > article'));
if (!cards.length) return;
const types = Array.from(new Set(cards.map(c => c.dataset.type).filter(Boolean))).sort();
const hasStatus = cards.some(c => c.dataset.status);
const state = { q: '', type: 'all', status: 'all' };
function chip(label, group, value, active) {
const cls = active
? 'bg-primary text-primary-foreground'
: 'bg-card ring-card text-muted-foreground hover:bg-muted hover:text-foreground';
return `<button type="button" data-group="${group}" data-value="${value}"
class="inline-flex h-7 items-center rounded-full px-3 font-mono text-[11px] uppercase tracking-wider transition-colors ${cls}">${label}</button>`;
}
function renderChips() {
const parts = [];
parts.push(chip('all', 'type', 'all', state.type === 'all'));
types.forEach(t => parts.push(chip(t.toLowerCase(), 'type', t, state.type === t)));
if (hasStatus) {
parts.push('<span class="mx-1 h-4 w-px bg-border"></span>');
parts.push(chip('any', 'status', 'all', state.status === 'all'));
parts.push(chip('active', 'status', 'active', state.status === 'active'));
parts.push(chip('expired', 'status', 'expired', state.status === 'expired'));
}
chips.innerHTML = parts.join('');
}
function apply() {
let shown = 0;
cards.forEach(c => {
const matchText = !state.q || (c.dataset.search || '').includes(state.q);
const matchType = state.type === 'all' || c.dataset.type === state.type;
const matchStatus = state.status === 'all' || c.dataset.status === state.status;
const show = matchText && matchType && matchStatus;
c.style.display = show ? '' : 'none';
if (show) shown++;
});
empty.classList.toggle('hidden', shown > 0);
}
chips.addEventListener('click', e => {
const btn = e.target.closest('button[data-group]');
if (!btn) return;
state[btn.dataset.group] = btn.dataset.value;
renderChips();
apply();
});
input.addEventListener('input', () => {
state.q = input.value.toLowerCase().trim();
apply();
});
renderChips();
})();
</script>
@@ -1,35 +1,48 @@
Schematics
SCHEMATICS
<h2>Schematic Download</h2>
<label for="schemList"><h5>A list of schematics is below. You can click on the schematic name to download it.</h5>
</label>
<div class="input-group mb-3 w-75 p-3">
<input type="text" autocomplete="off" autofocus class="form-control" oninput="filterTable(this.value)"
placeholder="Search for a schematic...">
</div>
<table id="schemList" class="table table-striped table-bordered">
<thead>
<section class="rise flex flex-wrap items-end justify-between gap-3">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Schematics</h1>
<a href="/api/schematics/upload/"
class="inline-flex h-9 items-center gap-1.5 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/85">
<svg class="size-3.5" aria-hidden="true"><use href="#i-upload"/></svg>
Upload
</a>
</section>
<section class="rise rise-1 mt-6">
<label class="ring-card relative flex h-10 w-full items-center gap-2 rounded-full bg-card px-3 transition-colors focus-within:bg-background focus-within:ring-2 focus-within:ring-ring/30 sm:max-w-md">
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-search"/></svg>
<input id="schem-filter"
type="text"
oninput="filterTable(this.value)"
placeholder="Filter schematics..."
class="h-full flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autocomplete="off">
</label>
</section>
<section class="rise rise-2 ring-card mt-4 overflow-hidden rounded-2xl bg-card">
<table id="schemList" class="w-full text-sm">
<thead class="border-b border-border/60 bg-muted/40">
<tr>
<th scope="col">Name</th>
<th scope="col">Size</th>
<th scope="col" class="px-4 py-2.5 text-left font-mono text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Name</th>
<th scope="col" class="px-4 py-2.5 text-right font-mono text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Size</th>
<th scope="col" class="w-8"></th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-border/60" translate="no">
${schematics}
</tbody>
</table>
<script>
let schemList = document.getElementById("schemList").getElementsByTagName("tbody")[0].getElementsByTagName("tr");
</table>
</section>
<script>
const schemList = document.getElementById('schemList').getElementsByTagName('tbody')[0].getElementsByTagName('tr');
function filterTable(query) {
const q = (query || '').toLowerCase();
for (let i = 0; i < schemList.length; i++) {
let th = schemList[i].getElementsByTagName("th")[0];
let schemName = th.textContent || th.innerText;
if (schemName.toLowerCase().includes(query.toLowerCase())) {
schemList[i].style.display = "";
} else {
schemList[i].style.display = "none";
}
const name = (schemList[i].getAttribute('data-name') || '').toLowerCase();
schemList[i].style.display = !q || name.includes(q) ? '' : 'none';
}
}
</script>
+33 -10
View File
@@ -1,13 +1,36 @@
Schematics
SCHEMATICS
<h2>Schematic Upload</h2>
<div class="cos-xs-8 col-lg-5">
<label for="formFile" class="form-label"><h5>Please select a schematic file to upload.</h5></label>
<form class="input-group justify-content-center" enctype="multipart/form-data" method="post"
action="/api/schematics/uploading">
<div class="input-group">
<input type="file" class="form-control" id="formFile" name="file" aria-describedby="formFile" aria-label="Upload">
<button class="btn btn-outline-primary" type="submit">Upload</button>
</div>
<section class="rise">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Upload schematic</h1>
</section>
<section class="rise rise-1 mt-6 max-w-2xl">
<form enctype="multipart/form-data" method="post" action="/api/schematics/uploading"
class="ring-card flex flex-col gap-4 rounded-2xl bg-card p-5 sm:flex-row sm:items-center">
<label for="formFile"
class="flex flex-1 cursor-pointer items-center gap-3 rounded-xl border border-dashed border-border bg-muted/30 px-4 py-3 text-sm transition-colors hover:border-foreground/30 hover:bg-muted/50">
<svg class="size-5 text-muted-foreground" aria-hidden="true"><use href="#i-upload"/></svg>
<span class="text-muted-foreground">
<span class="text-foreground">Choose a file</span> &mdash; or drag it onto this field
</span>
<input id="formFile" type="file" name="file" class="sr-only">
</label>
<button type="submit"
class="inline-flex h-10 items-center justify-center gap-1.5 rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/85">
Upload
<svg class="size-3.5" aria-hidden="true"><use href="#i-arrow-right"/></svg>
</button>
</form>
</div>
<p id="picked-name" class="mt-3 font-mono text-[11px] text-muted-foreground"></p>
</section>
<script>
(function () {
const input = document.getElementById('formFile');
const out = document.getElementById('picked-name');
if (!input || !out) return;
input.addEventListener('change', () => {
out.textContent = input.files && input.files[0] ? 'selected: ' + input.files[0].name : '';
});
})();
</script>
@@ -1,4 +1,18 @@
Schematics
SCHEMATICS
<h2>Schematic Upload</h2>
<h5 class="alert alert-danger mb-3 w-auto p-3" role="alert"><b>Error:</b> ${MESSAGE}</h5>
<section class="rise">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Upload schematic</h1>
</section>
<div class="rise rise-1 ring-card mt-6 flex items-start gap-3 rounded-2xl bg-card p-4">
<svg class="mt-0.5 size-5 shrink-0 text-destructive" aria-hidden="true"><use href="#i-alert"/></svg>
<p class="text-sm text-muted-foreground">${MESSAGE}</p>
</div>
<div class="rise rise-2 mt-4">
<a href="/api/schematics/upload/"
class="inline-flex h-9 items-center gap-1.5 rounded-full bg-muted px-4 text-sm font-medium transition-colors hover:bg-secondary">
<svg class="size-3.5" aria-hidden="true"><use href="#i-upload"/></svg>
Try again
</a>
</div>
@@ -1,4 +1,23 @@
Schematics
SCHEMATICS
<h2>Schematic Upload</h2>
<h5 class="alert alert-success mb-3 w-auto p-3" role="alert">${MESSAGE}</h5>
<section class="rise">
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Upload schematic</h1>
</section>
<div class="rise rise-1 ring-card mt-6 flex items-start gap-3 rounded-2xl bg-card p-4">
<svg class="mt-0.5 size-5 shrink-0 text-success" aria-hidden="true"><use href="#i-check"/></svg>
<p class="text-sm text-muted-foreground">${MESSAGE}</p>
</div>
<div class="rise rise-2 mt-4 flex flex-wrap gap-2">
<a href="/api/schematics/upload/"
class="inline-flex h-9 items-center gap-1.5 rounded-full bg-muted px-4 text-sm font-medium transition-colors hover:bg-secondary">
<svg class="size-3.5" aria-hidden="true"><use href="#i-upload"/></svg>
Upload another
</a>
<a href="/api/schematics/download/"
class="inline-flex h-9 items-center gap-1.5 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/85">
<svg class="size-3.5" aria-hidden="true"><use href="#i-arrow-right"/></svg>
View library
</a>
</div>
+339 -45
View File
@@ -1,59 +1,353 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-KK94CHFLLe+nY2dmCWGMq91rCGa5gtU4mk92HdvYe+M/SXH301p5ILy+dN9+nJOZ" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ENjdO4Dr2bkBIFxQpeoTz1HIcje39Wm4jDKdf19U8gI4ddQ3GYNS7NTKfAdVQSZe"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/Telesphoreo/bootstrap-color-switcher@master/script.js"></script>
<title>${TITLE} &middot; Plex HTTPD</title>
<script>
(function () {
try {
const stored = localStorage.getItem('plex-theme');
const dark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
if (dark) document.documentElement.classList.add('dark');
} catch (e) {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.classList.add('dark');
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@200;400&display=swap" rel="stylesheet">
<title>${TITLE} - Plex HTTPD</title>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Geist:wght@300..900&family=Geist+Mono:wght@400;500;600&display=swap">
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, 'JetBrains Mono', monospace;
--radius: 0.625rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: oklch(1 0 0);
--color-foreground: oklch(0.145 0 0);
--color-card: oklch(1 0 0);
--color-card-foreground: oklch(0.145 0 0);
--color-popover: oklch(1 0 0);
--color-popover-foreground: oklch(0.145 0 0);
--color-primary: oklch(0.555 0.265 264);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.97 0 0);
--color-secondary-foreground: oklch(0.205 0 0);
--color-muted: oklch(0.97 0 0);
--color-muted-foreground: oklch(0.556 0 0);
--color-accent: oklch(0.97 0 0);
--color-accent-foreground: oklch(0.205 0 0);
--color-destructive: oklch(0.58 0.22 27);
--color-success: oklch(0.62 0.18 145);
--color-warning: oklch(0.74 0.16 75);
--color-border: oklch(0.922 0 0);
--color-input: oklch(0.922 0 0);
--color-ring: oklch(0.555 0.265 264);
--color-surface: oklch(0.98 0 0);
--color-surface-foreground: oklch(0.145 0 0);
}
</style>
<style>
.dark {
--color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.985 0 0);
--color-card: oklch(0.205 0 0);
--color-card-foreground: oklch(0.985 0 0);
--color-popover: oklch(0.205 0 0);
--color-popover-foreground: oklch(0.985 0 0);
--color-primary: oklch(0.62 0.235 264);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.269 0 0);
--color-secondary-foreground: oklch(0.985 0 0);
--color-muted: oklch(0.269 0 0);
--color-muted-foreground: oklch(0.708 0 0);
--color-accent: oklch(0.371 0 0);
--color-accent-foreground: oklch(0.985 0 0);
--color-destructive: oklch(0.704 0.191 22);
--color-success: oklch(0.74 0.18 145);
--color-warning: oklch(0.82 0.16 75);
--color-border: oklch(1 0 0 / 10%);
--color-input: oklch(1 0 0 / 15%);
--color-ring: oklch(0.62 0.235 264);
--color-surface: oklch(0.2 0 0);
--color-surface-foreground: oklch(0.708 0 0);
}
</style>
<style>
:root {
color-scheme: light dark;
}
html {
scrollbar-gutter: stable;
}
body {
font-family: 'Geist', ui-sans-serif, system-ui, sans-serif;
font-feature-settings: 'cv11', 'ss01';
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
position: relative;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(circle at 1px 1px, oklch(from var(--color-foreground) l c h / 0.05) 1px, transparent 0);
background-size: 28px 28px;
mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
pointer-events: none;
z-index: 0;
}
body::after {
content: '';
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 60vh;
background: linear-gradient(to top,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.200) 0%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.162) 5%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.131) 10%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.106) 15%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.086) 20%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.069) 25%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.056) 30%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.045) 35%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.037) 40%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.030) 45%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.024) 50%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.019) 55%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.016) 60%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.013) 65%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.010) 70%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.008) 75%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.006) 80%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.004) 85%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.002) 90%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0.001) 95%,
oklch(from var(--color-primary) calc(l - 0.05) c h / 0) 100%);
pointer-events: none;
z-index: 0;
}
.layer-content { position: relative; z-index: 1; }
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.rise { animation: rise 0.55s cubic-bezier(0.16, 0.84, 0.32, 1) backwards; }
.rise-1 { animation-delay: 0.04s; }
.rise-2 { animation-delay: 0.08s; }
.rise-3 { animation-delay: 0.12s; }
.rise-4 { animation-delay: 0.16s; }
.rise-5 { animation-delay: 0.20s; }
.rise-6 { animation-delay: 0.24s; }
@keyframes statusPulse {
0%, 100% { box-shadow: 0 0 0 0 oklch(from var(--color-success) l c h / 0.5); }
50% { box-shadow: 0 0 0 6px oklch(from var(--color-success) l c h / 0); }
}
.status-dot { animation: statusPulse 2.4s ease-in-out infinite; }
@keyframes tick {
0% { background-color: oklch(from var(--color-primary) l c h / 0.18); }
100% { background-color: transparent; }
}
.tick { animation: tick 0.55s ease-out; }
.tabular { font-variant-numeric: tabular-nums; }
/* Maia: subtle ring on cards, no shadow */
.ring-card { box-shadow: inset 0 0 0 1px oklch(from var(--color-foreground) l c h / 0.08); }
/* Hide scrollbar but keep functionality */
.nav-scroll::-webkit-scrollbar { display: none; }
.nav-scroll { scrollbar-width: none; }
</style>
</head>
<body style="font-family: 'IBM Plex Sans', sans-serif;">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="/" style="font-weight:200;">Plex HTTPD</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link ${ACTIVE_HOME}" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link ${ACTIVE_INDEFBANS}" href="/api/indefbans/">Indefinite Bans</a>
</li>
<li class="nav-item">
<a class="nav-link ${ACTIVE_LIST}" href="/api/list/">List</a>
</li>
<li class="nav-item">
<a class="nav-link ${ACTIVE_PUNISHMENTS}" href="/api/punishments/">Punishments</a>
</li>
<li>
<a class="nav-link ${ACTIVE_COMMANDS}" href="/api/commands/">Commands</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle ${ACTIVE_SCHEMATICS}" id="navbarDropdownMenuLink" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
<body class="bg-background text-foreground min-h-screen antialiased">
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="i-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M13.6903 19.4567C13.5 18.9973 13.5 18.4149 13.5 17.25C13.5 16.0851 13.5 15.5027 13.6903 15.0433C13.944 14.4307 14.4307 13.944 15.0433 13.6903C15.5027 13.5 16.0851 13.5 17.25 13.5C18.4149 13.5 18.9973 13.5 19.4567 13.6903C20.0693 13.944 20.556 14.4307 20.8097 15.0433C21 15.5027 21 16.0851 21 17.25C21 18.4149 21 18.9973 20.8097 19.4567C20.556 20.0693 20.0693 20.556 19.4567 20.8097C18.9973 21 18.4149 21 17.25 21C16.0851 21 15.5027 21 15.0433 20.8097C14.4307 20.556 13.944 20.0693 13.6903 19.4567Z"/>
<path d="M13.6903 8.95671C13.5 8.49728 13.5 7.91485 13.5 6.75C13.5 5.58515 13.5 5.00272 13.6903 4.54329C13.944 3.93072 14.4307 3.44404 15.0433 3.1903C15.5027 3 16.0851 3 17.25 3C18.4149 3 18.9973 3 19.4567 3.1903C20.0693 3.44404 20.556 3.93072 20.8097 4.54329C21 5.00272 21 5.58515 21 6.75C21 7.91485 21 8.49728 20.8097 8.95671C20.556 9.56928 20.0693 10.056 19.4567 10.3097C18.9973 10.5 18.4149 10.5 17.25 10.5C16.0851 10.5 15.5027 10.5 15.0433 10.3097C14.4307 10.056 13.944 9.56928 13.6903 8.95671Z"/>
<path d="M3.1903 19.4567C3 18.9973 3 18.4149 3 17.25C3 16.0851 3 15.5027 3.1903 15.0433C3.44404 14.4307 3.93072 13.944 4.54329 13.6903C5.00272 13.5 5.58515 13.5 6.75 13.5C7.91485 13.5 8.49728 13.5 8.95671 13.6903C9.56928 13.944 10.056 14.4307 10.3097 15.0433C10.5 15.5027 10.5 16.0851 10.5 17.25C10.5 18.4149 10.5 18.9973 10.3097 19.4567C10.056 20.0693 9.56928 20.556 8.95671 20.8097C8.49728 21 7.91485 21 6.75 21C5.58515 21 5.00272 21 4.54329 20.8097C3.93072 20.556 3.44404 20.0693 3.1903 19.4567Z"/>
<path d="M3.1903 8.95671C3 8.49728 3 7.91485 3 6.75C3 5.58515 3 5.00272 3.1903 4.54329C3.44404 3.93072 3.93072 3.44404 4.54329 3.1903C5.00272 3 5.58515 3 6.75 3C7.91485 3 8.49728 3 8.95671 3.1903C9.56928 3.44404 10.056 3.93072 10.3097 4.54329C10.5 5.00272 10.5 5.58515 10.5 6.75C10.5 7.91485 10.5 8.49728 10.3097 8.95671C10.056 9.56928 9.56928 10.056 8.95671 10.3097C8.49728 10.5 7.91485 10.5 6.75 10.5C5.58515 10.5 5.00272 10.5 4.54329 10.3097C3.93072 10.056 3.44404 9.56928 3.1903 8.95671Z"/>
</symbol>
<symbol id="i-users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 11C13 8.79086 11.2091 7 9 7C6.79086 7 5 8.79086 5 11C5 13.2091 6.79086 15 9 15C11.2091 15 13 13.2091 13 11Z"/>
<path d="M11.0386 7.55773C11.0131 7.37547 11 7.18927 11 7C11 4.79086 12.7909 3 15 3C17.2091 3 19 4.79086 19 7C19 9.20914 17.2091 11 15 11C14.2554 11 13.5584 10.7966 12.9614 10.4423"/>
<path d="M15 21C15 17.6863 12.3137 15 9 15C5.68629 15 3 17.6863 3 21"/>
<path d="M21 17C21 13.6863 18.3137 11 15 11"/>
</symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.7088 3.49534C16.8165 2.55382 14.5009 2 12 2C9.4991 2 7.1835 2.55382 5.29116 3.49534C4.36318 3.95706 3.89919 4.18792 3.4496 4.91378C3 5.63965 3 6.34248 3 7.74814V11.2371C3 16.9205 7.54236 20.0804 10.173 21.4338C10.9067 21.8113 11.2735 22 12 22C12.7265 22 13.0933 21.8113 13.8269 21.4338C16.4576 20.0804 21 16.9205 21 11.2371L21 7.74814C21 6.34249 21 5.63966 20.5504 4.91378C20.1008 4.18791 19.6368 3.95706 18.7088 3.49534Z"/>
<path d="M10 10V8.5C10 7.39543 10.8954 6.5 12 6.5C13.1046 6.5 14 7.39543 14 8.5V10"/>
<path d="M14 10H10C9.17157 10 8.5 10.6716 8.5 11.5V13C8.5 13.8284 9.17157 14.5 10 14.5H14C14.8284 14.5 15.5 13.8284 15.5 13V11.5C15.5 10.6716 14.8284 10 14 10Z"/>
</symbol>
<symbol id="i-gavel" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 14V10C3 6.22876 3 4.34315 4.17157 3.17157C5.34315 2 7.22876 2 11 2H13C16.7712 2 18.6569 2 19.8284 3.17157C21 4.34315 21 6.22876 21 10V14C21 17.7712 21 19.6569 19.8284 20.8284C18.6569 22 16.7712 22 13 22H11C7.22876 22 5.34315 22 4.17157 20.8284C3 19.6569 3 17.7712 3 14Z"/>
<path d="M11.3333 10.6667C12.3883 11.7216 13.7778 12.7937 13.7778 12.7937L15.6825 10.8889C15.6825 10.8889 14.6105 9.49939 13.5556 8.44444C12.5006 7.3895 11.1111 6.31746 11.1111 6.31746L9.20635 8.22222C9.20635 8.22222 10.2784 9.61172 11.3333 10.6667ZM11.3333 10.6667L8 14M16 10.5714L13.4603 13.1111M11.4286 6L8.88889 8.53968"/>
<path d="M8 18H16"/>
</symbol>
<symbol id="i-code" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 8L18.8398 9.85008C19.6133 10.6279 20 11.0168 20 11.5C20 11.9832 19.6133 12.3721 18.8398 13.1499L17 15"/>
<path d="M7 8L5.16019 9.85008C4.38673 10.6279 4 11.0168 4 11.5C4 11.9832 4.38673 12.3721 5.16019 13.1499L7 15"/>
<path d="M14.5 4L9.5 20"/>
</symbol>
<symbol id="i-package" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.5 7.5V13.5C2.5 17.2712 2.5 19.1569 3.67157 20.3284C4.84315 21.5 6.72876 21.5 10.5 21.5H12M21.5 12.5V7.5"/>
<path d="M3.86909 5.31461L2.5 7.5H21.5L20.2478 5.41303C19.3941 3.99021 18.9673 3.2788 18.2795 2.8894C17.5918 2.5 16.7621 2.5 15.1029 2.5H8.95371C7.32998 2.5 6.51812 2.5 5.84013 2.8753C5.16215 3.2506 4.73113 3.93861 3.86909 5.31461Z"/>
<path d="M12 7.5V2.5"/>
<path d="M10 10.5H14"/>
<path d="M14.5 19.5C14.5 19.5 15.5 19.5 16.5 21.5C16.5 21.5 18.6765 16.5 21.5 15.5"/>
</symbol>
<symbol id="i-chip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.8284 18.8284C17.6569 20 15.7712 20 12 20C8.22876 20 6.34315 20 5.17157 18.8284C4 17.6569 4 15.7712 4 12C4 8.22876 4 6.34315 5.17157 5.17157C6.34315 4 8.22876 4 12 4C15.7712 4 17.6569 4 18.8284 5.17157C20 6.34315 20 8.22876 20 12C20 15.7712 20 17.6569 18.8284 18.8284Z"/>
<path d="M8 2V4M16 2V4M12 2V4M8 20V22M12 20V22M16 20V22M22 16H20M4 8H2M4 16H2M4 12H2M22 8H20M22 12H20"/>
<path d="M15 12C15 13.6569 13.6569 15 12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12Z"/>
</symbol>
<symbol id="i-database" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12C3 7.75736 3 5.63604 4.31802 4.31802C5.63604 3 7.75736 3 12 3C16.2426 3 18.364 3 19.682 4.31802C21 5.63604 21 7.75736 21 12C21 16.2426 21 18.364 19.682 19.682C18.364 21 16.2426 21 12 21C7.75736 21 5.63604 21 4.31802 19.682C3 18.364 3 16.2426 3 12Z"/>
<path d="M3 12H21"/>
<path d="M11 7.5L17 7.5"/>
<circle cx="7" cy="7.5" r="0.6" fill="currentColor"/>
<path d="M11 16.5L17 16.5"/>
<circle cx="7" cy="16.5" r="0.6" fill="currentColor"/>
</symbol>
<symbol id="i-chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 21H10C6.70017 21 5.05025 21 4.02513 19.9749C3 18.9497 3 17.2998 3 14V3"/>
<path d="M5 20C5.43938 16.8438 7.67642 8.7643 10.4282 8.7643C12.3301 8.7643 12.8226 12.6353 14.6864 12.6353C17.8931 12.6353 17.4282 4 21 4"/>
</symbol>
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8V12L14 14"/>
</symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 17L21 21"/>
<path d="M19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11C3 15.4183 6.58172 19 11 19C15.4183 19 19 15.4183 19 11Z"/>
</symbol>
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.0092 2V5.13219C20.0092 5.42605 19.6418 5.55908 19.4537 5.33333C17.6226 3.2875 14.9617 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12"/>
</symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 17C3 17.93 3 18.395 3.10222 18.7765C3.37962 19.8117 4.18827 20.6204 5.22354 20.8978C5.60504 21 6.07003 21 7 21L17 21C17.93 21 18.395 21 18.7765 20.8978C19.8117 20.6204 20.6204 19.8117 20.8978 18.7765C21 18.395 21 17.93 21 17"/>
<path d="M16.5 7.5C16.5 7.5 13.1858 3 12 3C10.8141 3 7.5 7.5 7.5 7.5M12 4V16"/>
</symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 17C3 17.93 3 18.395 3.10222 18.7765C3.37962 19.8117 4.18827 20.6204 5.22354 20.8978C5.60504 21 6.07003 21 7 21L17 21C17.93 21 18.395 21 18.7765 20.8978C19.8117 20.6204 20.6204 19.8117 20.8978 18.7765C21 18.395 21 17.93 21 17"/>
<path d="M16.5 11.5C16.5 11.5 13.1858 16 12 16C10.8141 16 7.5 11.5 7.5 11.5M12 15V3"/>
</symbol>
<symbol id="i-arrow-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 6C9 6 15 10.4189 15 12C15 13.5812 9 18 9 18"/>
</symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12Z"/>
<path d="M8 12.5L10.5 15L16 9"/>
</symbol>
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8V12"/>
<circle cx="12" cy="15.75" r="0.5" fill="currentColor"/>
</symbol>
<symbol id="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</symbol>
<symbol id="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.4998 14.9764C20.071 15.6336 18.4805 16 16.8044 16C10.6249 16 5.6155 11.0294 5.6155 4.89773C5.6155 3.6 5.84001 2.35427 6.25244 1.19773C3.16614 2.65973 1 6.30099 1 10.4995C1 16.6311 6.0094 21.6017 12.1889 21.6017C16.4115 21.6017 20.0908 19.2848 21.4998 14.9764Z"/>
</symbol>
</defs>
</svg>
<div class="layer-content flex min-h-screen flex-col">
<header class="sticky top-0 z-50 border-b border-border/60 bg-background/75 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
<div class="mx-auto flex h-14 max-w-7xl items-center gap-6 px-6">
<a href="/" class="flex items-center gap-2.5 text-foreground transition-opacity hover:opacity-80">
<img src="/assets/plexlogo.webp" alt="" class="size-7 rounded-md" width="28" height="28">
<span class="text-sm font-semibold tracking-tight">Plex HTTPD</span>
</a>
<nav class="nav-scroll flex flex-1 items-center gap-1 overflow-x-auto">
<a class="nav-link ${ACTIVE_HOME} group inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[active=true]:bg-muted data-[active=true]:text-foreground" href="/">
<svg class="size-3.5 opacity-70 group-hover:opacity-100 group-data-[active=true]:text-primary group-data-[active=true]:opacity-100" aria-hidden="true"><use href="#i-dashboard"/></svg>
Overview
</a>
<a class="nav-link ${ACTIVE_PLAYERS} group inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[active=true]:bg-muted data-[active=true]:text-foreground" href="/players/">
<svg class="size-3.5 opacity-70 group-hover:opacity-100 group-data-[active=true]:text-primary group-data-[active=true]:opacity-100" aria-hidden="true"><use href="#i-users"/></svg>
Players
</a>
<a class="nav-link ${ACTIVE_COMMANDS} group inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[active=true]:bg-muted data-[active=true]:text-foreground" href="/api/commands/">
<svg class="size-3.5 opacity-70 group-hover:opacity-100 group-data-[active=true]:text-primary group-data-[active=true]:opacity-100" aria-hidden="true"><use href="#i-code"/></svg>
Commands
</a>
<a class="nav-link ${ACTIVE_PUNISHMENTS} group inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[active=true]:bg-muted data-[active=true]:text-foreground" href="/punishments/">
<svg class="size-3.5 opacity-70 group-hover:opacity-100 group-data-[active=true]:text-primary group-data-[active=true]:opacity-100" aria-hidden="true"><use href="#i-gavel"/></svg>
Punishments
</a>
<a class="nav-link ${ACTIVE_INDEFBANS} group inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[active=true]:bg-muted data-[active=true]:text-foreground" href="/indefbans/">
<svg class="size-3.5 opacity-70 group-hover:opacity-100 group-data-[active=true]:text-primary group-data-[active=true]:opacity-100" aria-hidden="true"><use href="#i-lock"/></svg>
Indef Bans
</a>
<a class="nav-link ${ACTIVE_SCHEMATICS} group inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[active=true]:bg-muted data-[active=true]:text-foreground" href="/api/schematics/download/">
<svg class="size-3.5 opacity-70 group-hover:opacity-100 group-data-[active=true]:text-primary group-data-[active=true]:opacity-100" aria-hidden="true"><use href="#i-package"/></svg>
Schematics
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<li><a class="dropdown-item" href="/api/schematics/download/">Download</a></li>
<li><a class="dropdown-item" href="/api/schematics/upload/">Upload</a></li>
</ul>
</li>
</ul>
</nav>
<div class="hidden items-center gap-2 md:flex">
<button type="button" onclick="window.plexToggleTheme()" class="ring-card inline-flex size-8 items-center justify-center rounded-full bg-card text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" aria-label="Toggle theme">
<svg class="size-4 hidden dark:block" aria-hidden="true"><use href="#i-sun"/></svg>
<svg class="size-4 block dark:hidden" aria-hidden="true"><use href="#i-moon"/></svg>
</button>
</div>
</div>
</nav>
<div style="text-align: center;" class="col-auto m-0 row justify-content-center p-4">
</header>
<main class="mx-auto w-full max-w-7xl flex-1 px-6 py-10 md:py-14">
${CONTENT}
</main>
</div>
<script>
window.plexToggleTheme = function () {
const isDark = document.documentElement.classList.toggle('dark');
try { localStorage.setItem('plex-theme', isDark ? 'dark' : 'light'); } catch (e) {}
};
document.querySelectorAll('.nav-link').forEach(a => {
if (a.classList.contains('active')) a.setAttribute('data-active', 'true');
});
</script>
</body>
</html>