mirror of
https://github.com/plexusorg/Module-HTTPD.git
synced 2026-06-04 00:56:54 +00:00
HTTPD performance improvements
This commit is contained in:
@@ -19,6 +19,7 @@ import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
|
|||||||
import org.eclipse.jetty.ee10.servlet.ServletHandler;
|
import org.eclipse.jetty.ee10.servlet.ServletHandler;
|
||||||
import org.eclipse.jetty.ee10.servlet.ServletHolder;
|
import org.eclipse.jetty.ee10.servlet.ServletHolder;
|
||||||
import org.eclipse.jetty.server.*;
|
import org.eclipse.jetty.server.*;
|
||||||
|
import org.eclipse.jetty.util.thread.QueuedThreadPool;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
@@ -66,7 +67,14 @@ public class HTTPDModule extends PlexModule
|
|||||||
|
|
||||||
serverThread = new Thread(() ->
|
serverThread = new Thread(() ->
|
||||||
{
|
{
|
||||||
Server server = new Server();
|
int maxThreads = moduleConfig.getInt("server.threads.max", 16);
|
||||||
|
int minThreads = Math.min(moduleConfig.getInt("server.threads.min", 2), maxThreads);
|
||||||
|
int idleTimeout = moduleConfig.getInt("server.threads.idle-timeout-ms", 30_000);
|
||||||
|
QueuedThreadPool pool = new QueuedThreadPool(maxThreads, minThreads, idleTimeout);
|
||||||
|
pool.setName("Plex-HTTPD");
|
||||||
|
pool.setDaemon(true);
|
||||||
|
|
||||||
|
Server server = new Server(pool);
|
||||||
ServletHandler servletHandler = new ServletHandler();
|
ServletHandler servletHandler = new ServletHandler();
|
||||||
|
|
||||||
context = new ServletContextHandler(ServletContextHandler.SESSIONS);
|
context = new ServletContextHandler(ServletContextHandler.SESSIONS);
|
||||||
@@ -74,10 +82,14 @@ public class HTTPDModule extends PlexModule
|
|||||||
context.setContextPath("/");
|
context.setContextPath("/");
|
||||||
HttpConfiguration configuration = new HttpConfiguration();
|
HttpConfiguration configuration = new HttpConfiguration();
|
||||||
configuration.addCustomizer(new ForwardedRequestCustomizer());
|
configuration.addCustomizer(new ForwardedRequestCustomizer());
|
||||||
|
configuration.setRequestHeaderSize(moduleConfig.getInt("server.limits.request-header-bytes", 8 * 1024));
|
||||||
|
configuration.setSendServerVersion(false);
|
||||||
HttpConnectionFactory factory = new HttpConnectionFactory(configuration);
|
HttpConnectionFactory factory = new HttpConnectionFactory(configuration);
|
||||||
ServerConnector connector = new ServerConnector(server, factory);
|
ServerConnector connector = new ServerConnector(server, factory);
|
||||||
connector.setHost(moduleConfig.getString("server.bind-address"));
|
connector.setHost(moduleConfig.getString("server.bind-address"));
|
||||||
connector.setPort(moduleConfig.getInt("server.port"));
|
connector.setPort(moduleConfig.getInt("server.port"));
|
||||||
|
connector.setIdleTimeout(moduleConfig.getLong("server.limits.idle-timeout-ms", 15_000L));
|
||||||
|
connector.setAcceptQueueSize(moduleConfig.getInt("server.limits.accept-queue", 32));
|
||||||
|
|
||||||
context.addFilter(new FilterHolder(new RateLimitFilter()), "/*", EnumSet.of(DispatcherType.REQUEST));
|
context.addFilter(new FilterHolder(new RateLimitFilter()), "/*", EnumSet.of(DispatcherType.REQUEST));
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import java.io.InputStream;
|
|||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.lang.reflect.InvocationTargetException;
|
import java.lang.reflect.InvocationTargetException;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.text.CharacterIterator;
|
import java.text.CharacterIterator;
|
||||||
import java.text.StringCharacterIterator;
|
import java.text.StringCharacterIterator;
|
||||||
@@ -153,7 +154,20 @@ public class AbstractServlet extends HttpServlet
|
|||||||
|
|
||||||
public static String signInPrompt(String action)
|
public static String signInPrompt(String action)
|
||||||
{
|
{
|
||||||
return "You must <a class=\"text-primary underline\" href=\"/oauth2/login\">sign in</a> as staff " + action + ".";
|
return signInPrompt(null, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String signInPrompt(HttpServletRequest request, String action)
|
||||||
|
{
|
||||||
|
String href = "/oauth2/login";
|
||||||
|
if (request != null)
|
||||||
|
{
|
||||||
|
String path = getRequestPath(request);
|
||||||
|
String query = request.getQueryString();
|
||||||
|
String returnTo = query == null || query.isEmpty() ? path : path + "?" + query;
|
||||||
|
href = href + "?return_to=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
return "You must <a class=\"text-primary underline\" href=\"" + href + "\">sign in</a> as staff " + action + ".";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String readFile(InputStream filename)
|
public static String readFile(InputStream filename)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public class SchematicUploadServlet extends HttpServlet
|
|||||||
AuthenticatedUser user = AbstractServlet.currentStaff(request);
|
AuthenticatedUser user = AbstractServlet.currentStaff(request);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
response.getWriter().println(schematicUploadBadHTML(AbstractServlet.signInPrompt("to upload schematics")));
|
response.getWriter().println(schematicUploadBadHTML(AbstractServlet.signInPrompt(request, "to upload schematics")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
File worldeditFolder = HTTPDModule.getWorldeditFolder();
|
File worldeditFolder = HTTPDModule.getWorldeditFolder();
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
public class AuthenticationEndpoint extends AbstractServlet
|
public class AuthenticationEndpoint extends AbstractServlet
|
||||||
{
|
{
|
||||||
|
private static final String RETURN_TO_COOKIE = "plex_return_to";
|
||||||
|
|
||||||
@GetMapping(endpoint = "/oauth2/login")
|
@GetMapping(endpoint = "/oauth2/login")
|
||||||
public String login(HttpServletRequest request, HttpServletResponse response) throws IOException
|
public String login(HttpServletRequest request, HttpServletResponse response) throws IOException
|
||||||
{
|
{
|
||||||
@@ -26,6 +31,20 @@ public class AuthenticationEndpoint extends AbstractServlet
|
|||||||
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Authentication is not enabled.");
|
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Authentication is not enabled.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String returnTo = sanitizeReturnTo(request.getParameter("return_to"));
|
||||||
|
String cookieValue = returnTo == null ? "" : URLEncoder.encode(returnTo, StandardCharsets.UTF_8);
|
||||||
|
Cookie returnCookie = new Cookie(RETURN_TO_COOKIE, cookieValue);
|
||||||
|
returnCookie.setHttpOnly(true);
|
||||||
|
returnCookie.setPath("/");
|
||||||
|
returnCookie.setMaxAge(returnTo == null ? 0 : 600);
|
||||||
|
returnCookie.setAttribute("SameSite", "Lax");
|
||||||
|
if (request.isSecure() || "https".equalsIgnoreCase(request.getHeader("X-Forwarded-Proto")))
|
||||||
|
{
|
||||||
|
returnCookie.setSecure(true);
|
||||||
|
}
|
||||||
|
response.addCookie(returnCookie);
|
||||||
|
|
||||||
response.sendRedirect(provider.buildAuthorizeUrl(request));
|
response.sendRedirect(provider.buildAuthorizeUrl(request));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -54,7 +73,12 @@ public class AuthenticationEndpoint extends AbstractServlet
|
|||||||
+ "<p>" + escape(e.getMessage()) + "</p>"
|
+ "<p>" + escape(e.getMessage()) + "</p>"
|
||||||
+ "<p><a href=\"/oauth2/login\">Try again</a></p>";
|
+ "<p><a href=\"/oauth2/login\">Try again</a></p>";
|
||||||
}
|
}
|
||||||
response.sendRedirect("/");
|
|
||||||
|
String raw = readCookie(request, RETURN_TO_COOKIE);
|
||||||
|
String decoded = raw == null || raw.isEmpty() ? null : URLDecoder.decode(raw, StandardCharsets.UTF_8);
|
||||||
|
String target = sanitizeReturnTo(decoded);
|
||||||
|
clearReturnToCookie(request, response);
|
||||||
|
response.sendRedirect(target == null ? "/" : target);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,12 +127,17 @@ public class AuthenticationEndpoint extends AbstractServlet
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String readSessionCookie(HttpServletRequest request)
|
private static String readSessionCookie(HttpServletRequest request)
|
||||||
|
{
|
||||||
|
return readCookie(request, OAuth2Provider.SESSION_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readCookie(HttpServletRequest request, String name)
|
||||||
{
|
{
|
||||||
Cookie[] cookies = request.getCookies();
|
Cookie[] cookies = request.getCookies();
|
||||||
if (cookies == null) return null;
|
if (cookies == null) return null;
|
||||||
for (Cookie cookie : cookies)
|
for (Cookie cookie : cookies)
|
||||||
{
|
{
|
||||||
if (OAuth2Provider.SESSION_COOKIE.equals(cookie.getName()))
|
if (name.equals(cookie.getName()))
|
||||||
{
|
{
|
||||||
return cookie.getValue();
|
return cookie.getValue();
|
||||||
}
|
}
|
||||||
@@ -116,6 +145,33 @@ public class AuthenticationEndpoint extends AbstractServlet
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void clearReturnToCookie(HttpServletRequest request, HttpServletResponse response)
|
||||||
|
{
|
||||||
|
Cookie clear = new Cookie(RETURN_TO_COOKIE, "");
|
||||||
|
clear.setHttpOnly(true);
|
||||||
|
clear.setPath("/");
|
||||||
|
clear.setMaxAge(0);
|
||||||
|
clear.setAttribute("SameSite", "Lax");
|
||||||
|
if (request.isSecure() || "https".equalsIgnoreCase(request.getHeader("X-Forwarded-Proto")))
|
||||||
|
{
|
||||||
|
clear.setSecure(true);
|
||||||
|
}
|
||||||
|
response.addCookie(clear);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sanitizeReturnTo(String value)
|
||||||
|
{
|
||||||
|
if (value == null || value.isEmpty()) return null;
|
||||||
|
if (!value.startsWith("/")) return null;
|
||||||
|
if (value.startsWith("//") || value.startsWith("/\\")) return null;
|
||||||
|
for (int i = 0; i < value.length(); i++)
|
||||||
|
{
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (c == '\n' || c == '\r' || c == '\\') return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
private static String escape(String s)
|
private static String escape(String s)
|
||||||
{
|
{
|
||||||
if (s == null) return "";
|
if (s == null) return "";
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class CommandsEndpoint extends AbstractServlet
|
|||||||
<svg class="size-4 text-muted-foreground transition-transform group-open:rotate-90" aria-hidden="true"><use href="#i-arrow-right"/></svg>
|
<svg class="size-4 text-muted-foreground transition-transform group-open:rotate-90" aria-hidden="true"><use href="#i-arrow-right"/></svg>
|
||||||
%s
|
%s
|
||||||
</span>
|
</span>
|
||||||
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
%d %s
|
%d %s
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
@@ -113,11 +113,11 @@ public class CommandsEndpoint extends AbstractServlet
|
|||||||
%s
|
%s
|
||||||
</header>
|
</header>
|
||||||
%s
|
%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]">
|
<dl class="mt-3 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 border-t border-border/60 pt-3 text-xs">
|
||||||
<dt class="text-muted-foreground uppercase tracking-wider">usage</dt>
|
<dt class="text-muted-foreground">usage</dt>
|
||||||
<dd class="text-foreground/80 break-all">%s</dd>
|
<dd class="font-mono text-foreground/80 break-all">%s</dd>
|
||||||
<dt class="text-muted-foreground uppercase tracking-wider">perm</dt>
|
<dt class="text-muted-foreground">perm</dt>
|
||||||
<dd class="text-foreground/80 break-all">%s</dd>
|
<dd class="font-mono text-foreground/80 break-all">%s</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
""".formatted(searchBlob, name, aliasMarkup, descMarkup, usage, permission);
|
""".formatted(searchBlob, name, aliasMarkup, descMarkup, usage, permission);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public class IndefBansEndpoint extends AbstractServlet
|
|||||||
AuthenticatedUser user = currentStaff(request);
|
AuthenticatedUser user = currentStaff(request);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
return indefbansHTML(signInPrompt("to view this page"));
|
return indefbansHTML(signInPrompt(request, "to view this page"));
|
||||||
}
|
}
|
||||||
|
|
||||||
response.setHeader("content-type", "application/json");
|
response.setHeader("content-type", "application/json");
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public class IndefBansUIEndpoint extends AbstractServlet
|
|||||||
AuthenticatedUser viewer = currentStaff(request);
|
AuthenticatedUser viewer = currentStaff(request);
|
||||||
if (viewer == null)
|
if (viewer == null)
|
||||||
{
|
{
|
||||||
return errorHTML(signInPrompt("to view this page"));
|
return errorHTML(signInPrompt(request, "to view this page"));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<IndefiniteBan> bans = Plex.get().getPunishmentManager().getIndefiniteBans();
|
List<IndefiniteBan> bans = Plex.get().getPunishmentManager().getIndefiniteBans();
|
||||||
@@ -66,24 +66,24 @@ public class IndefBansUIEndpoint extends AbstractServlet
|
|||||||
StringBuilder rows = new StringBuilder();
|
StringBuilder rows = new StringBuilder();
|
||||||
if (!ban.getUsernames().isEmpty())
|
if (!ban.getUsernames().isEmpty())
|
||||||
{
|
{
|
||||||
rows.append(renderRow("users", "text-foreground/90 break-all", ban.getUsernames().stream().map(IndefBansUIEndpoint::escapeHtml).toList()));
|
rows.append(renderRow("Users", "text-foreground/90 break-all", ban.getUsernames().stream().map(IndefBansUIEndpoint::escapeHtml).toList()));
|
||||||
}
|
}
|
||||||
if (!ban.getUuids().isEmpty())
|
if (!ban.getUuids().isEmpty())
|
||||||
{
|
{
|
||||||
rows.append(renderRow("uuids", "text-foreground/55 break-all", ban.getUuids().stream().map(UUID::toString).toList()));
|
rows.append(renderRow("UUIDs", "font-mono text-foreground/55 break-all", ban.getUuids().stream().map(UUID::toString).toList()));
|
||||||
}
|
}
|
||||||
if (!ban.getIps().isEmpty())
|
if (!ban.getIps().isEmpty())
|
||||||
{
|
{
|
||||||
rows.append(renderRow("ips", "text-warning break-all", ban.getIps().stream().map(IndefBansUIEndpoint::escapeHtml).toList()));
|
rows.append(renderRow("IPs", "font-mono text-warning break-all", ban.getIps().stream().map(IndefBansUIEndpoint::escapeHtml).toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return """
|
return """
|
||||||
<article class="ring-card rounded-2xl bg-card p-5">
|
<article class="ring-card rounded-2xl bg-card p-5">
|
||||||
<header class="flex flex-wrap items-baseline justify-between gap-3">
|
<header class="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
<p class="text-sm">%s</p>
|
<p class="text-sm">%s</p>
|
||||||
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">%d %s</span>
|
<span class="text-xs text-muted-foreground">%d %s</span>
|
||||||
</header>
|
</header>
|
||||||
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 border-t border-border/60 pt-3 font-mono text-[11px]">
|
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 border-t border-border/60 pt-3 text-xs">
|
||||||
%s
|
%s
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
@@ -98,7 +98,7 @@ public class IndefBansUIEndpoint extends AbstractServlet
|
|||||||
items.append("<span>").append(value).append("</span>");
|
items.append("<span>").append(value).append("</span>");
|
||||||
}
|
}
|
||||||
return """
|
return """
|
||||||
<dt class="text-muted-foreground uppercase tracking-wider">%s</dt>
|
<dt class="text-muted-foreground">%s</dt>
|
||||||
<dd class="flex flex-wrap gap-x-3 gap-y-1 %s">%s</dd>
|
<dd class="flex flex-wrap gap-x-3 gap-y-1 %s">%s</dd>
|
||||||
""".formatted(label, valueClasses, items);
|
""".formatted(label, valueClasses, items);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,108 @@
|
|||||||
package dev.plex.request.impl;
|
package dev.plex.request.impl;
|
||||||
|
|
||||||
|
import dev.plex.Plex;
|
||||||
import dev.plex.request.AbstractServlet;
|
import dev.plex.request.AbstractServlet;
|
||||||
import dev.plex.request.GetMapping;
|
import dev.plex.request.GetMapping;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
public class PlayersEndpoint extends AbstractServlet
|
public class PlayersEndpoint extends AbstractServlet
|
||||||
{
|
{
|
||||||
|
private static volatile List<PlayerSnapshot> snapshot = Collections.emptyList();
|
||||||
|
private static volatile int maxPlayers = 0;
|
||||||
|
private static volatile boolean schedulerStarted = false;
|
||||||
|
|
||||||
|
public PlayersEndpoint()
|
||||||
|
{
|
||||||
|
super();
|
||||||
|
startSnapshotTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static synchronized void startSnapshotTask()
|
||||||
|
{
|
||||||
|
if (schedulerStarted) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Bukkit.getScheduler().runTaskTimer(Plex.get(), PlayersEndpoint::refreshSnapshot, 0L, 20L);
|
||||||
|
schedulerStarted = true;
|
||||||
|
}
|
||||||
|
catch (Throwable ignored)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void refreshSnapshot()
|
||||||
|
{
|
||||||
|
List<PlayerSnapshot> next = new ArrayList<>();
|
||||||
|
for (Player p : Bukkit.getOnlinePlayers())
|
||||||
|
{
|
||||||
|
next.add(PlayerSnapshot.of(p));
|
||||||
|
}
|
||||||
|
snapshot = List.copyOf(next);
|
||||||
|
maxPlayers = Bukkit.getMaxPlayers();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping(endpoint = "/players/")
|
@GetMapping(endpoint = "/players/")
|
||||||
public String getPlayers(HttpServletRequest request, HttpServletResponse response)
|
public String getPlayers(HttpServletRequest request, HttpServletResponse response)
|
||||||
{
|
{
|
||||||
Collection<? extends Player> players = Bukkit.getOnlinePlayers();
|
List<PlayerSnapshot> players = snapshot;
|
||||||
String cards = players.isEmpty() ? emptyState() : renderPlayerCards(players);
|
String cards = players.isEmpty() ? emptyState() : renderPlayerCards(players);
|
||||||
|
|
||||||
String file = readFile(this.getClass().getResourceAsStream("/httpd/players.html"));
|
String file = readFile(this.getClass().getResourceAsStream("/httpd/players.html"));
|
||||||
file = file.replace("${player_count}", String.valueOf(players.size()));
|
file = file.replace("${player_count}", String.valueOf(players.size()));
|
||||||
file = file.replace("${player_max}", String.valueOf(Bukkit.getMaxPlayers()));
|
file = file.replace("${player_max}", String.valueOf(maxPlayers));
|
||||||
file = file.replace("${player_cards}", cards);
|
file = file.replace("${player_cards}", cards);
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String renderPlayerCards(Collection<? extends Player> players)
|
private static String renderPlayerCards(List<PlayerSnapshot> players)
|
||||||
{
|
{
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
for (Player p : players)
|
for (PlayerSnapshot p : players)
|
||||||
{
|
{
|
||||||
sb.append(renderCard(p));
|
sb.append(renderCard(p));
|
||||||
}
|
}
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String renderCard(Player p)
|
private static String renderCard(PlayerSnapshot p)
|
||||||
{
|
{
|
||||||
String uuid = p.getUniqueId().toString();
|
String pingColor = p.ping < 80 ? "text-success" : p.ping < 200 ? "text-warning" : "text-destructive";
|
||||||
String name = escapeHtml(p.getName());
|
String opChip = p.op
|
||||||
String gamemode = p.getGameMode().name().toLowerCase();
|
? "<span class=\"inline-flex h-5 items-center rounded-full bg-primary/12 px-2 text-xs text-primary\">op</span>"
|
||||||
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>"
|
|
||||||
: "";
|
: "";
|
||||||
|
String location = p.world.isEmpty() ? "" : "In " + p.world;
|
||||||
|
String separator = location.isEmpty() ? "" : "<span class=\"text-foreground/30\">·</span>";
|
||||||
|
|
||||||
return """
|
return """
|
||||||
<article class="ring-card group rounded-2xl bg-card p-4 transition-colors hover:bg-secondary/50" data-name="%s">
|
<a href="/punishments/%s"
|
||||||
<div class="flex items-center gap-3">
|
class="ring-card group flex items-center gap-3 rounded-2xl bg-card p-3 transition-colors hover:bg-secondary/50"
|
||||||
<img class="size-12 rounded-xl bg-muted [image-rendering:pixelated]"
|
data-name="%s"
|
||||||
|
title="View punishments for %s">
|
||||||
|
<img class="size-10 rounded-lg bg-muted [image-rendering:pixelated]"
|
||||||
src="https://vzge.me/face/512/%s.png"
|
src="https://vzge.me/face/512/%s.png"
|
||||||
alt="" loading="lazy" width="48" height="48">
|
alt="" loading="lazy" width="40" height="40">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="truncate font-medium">%s</span>
|
<span class="truncate text-sm font-medium">%s</span>
|
||||||
%s
|
%s
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[11px] text-muted-foreground">
|
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs 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>
|
<span>%s</span>
|
||||||
</div>
|
%s
|
||||||
</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>
|
<span class="tabular %s">%dms</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
""".formatted(name, uuid, name, opChip, gamemode, world, pingColor, ping);
|
</a>
|
||||||
|
""".formatted(p.uuid, p.name, p.name, p.uuid, p.name, opChip, location, separator, pingColor, p.ping);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String emptyState()
|
private static String emptyState()
|
||||||
@@ -83,18 +115,6 @@ public class PlayersEndpoint extends AbstractServlet
|
|||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int safePing(Player p)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return p.getPing();
|
|
||||||
}
|
|
||||||
catch (Throwable t)
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String escapeHtml(String s)
|
private static String escapeHtml(String s)
|
||||||
{
|
{
|
||||||
if (s == null) return "";
|
if (s == null) return "";
|
||||||
@@ -103,4 +123,19 @@ public class PlayersEndpoint extends AbstractServlet
|
|||||||
.replace(">", ">")
|
.replace(">", ">")
|
||||||
.replace("\"", """);
|
.replace("\"", """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record PlayerSnapshot(UUID uuid, String name, String world, boolean op, int ping)
|
||||||
|
{
|
||||||
|
static PlayerSnapshot of(Player p)
|
||||||
|
{
|
||||||
|
int ping;
|
||||||
|
try { ping = p.getPing(); } catch (Throwable t) { ping = 0; }
|
||||||
|
return new PlayerSnapshot(
|
||||||
|
p.getUniqueId(),
|
||||||
|
escapeHtml(p.getName()),
|
||||||
|
escapeHtml(p.getWorld() != null ? p.getWorld().getName() : ""),
|
||||||
|
p.isOp(),
|
||||||
|
ping);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,12 +97,12 @@ public class PunishmentsUIEndpoint extends AbstractServlet
|
|||||||
if (p.isActive())
|
if (p.isActive())
|
||||||
{
|
{
|
||||||
status = "active";
|
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>";
|
statusChip = "<span class=\"inline-flex h-5 items-center rounded-full bg-destructive/10 px-2 text-xs text-destructive\">Active</span>";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
status = "expired";
|
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>";
|
statusChip = "<span class=\"inline-flex h-5 items-center rounded-full bg-muted px-2 text-xs text-muted-foreground\">Expired</span>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,29 +112,30 @@ public class PunishmentsUIEndpoint extends AbstractServlet
|
|||||||
{
|
{
|
||||||
ipBlob = p.getIp();
|
ipBlob = p.getIp();
|
||||||
ipRow = """
|
ipRow = """
|
||||||
<dt class="text-muted-foreground uppercase tracking-wider">IP</dt>
|
<dt class="text-muted-foreground">IP</dt>
|
||||||
<dd class="text-foreground/80 break-all">%s</dd>
|
<dd class="font-mono text-foreground/80 break-all">%s</dd>
|
||||||
""".formatted(escapeHtml(p.getIp()));
|
""".formatted(escapeHtml(p.getIp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
String searchBlob = escapeHtml((typeName + " " + rawReason + " " + punisher + " " + status + " " + ipBlob).toLowerCase());
|
String searchBlob = escapeHtml((typeName + " " + rawReason + " " + punisher + " " + status + " " + ipBlob).toLowerCase());
|
||||||
|
String typeLabel = titleCase(typeName);
|
||||||
|
|
||||||
return """
|
return """
|
||||||
<article class="ring-card rounded-2xl bg-card p-5" data-search="%s" data-type="%s" data-status="%s">
|
<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">
|
<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>
|
<span class="inline-flex h-6 items-center rounded-full bg-%s/10 px-2.5 text-xs font-medium text-%s">%s</span>
|
||||||
%s
|
%s
|
||||||
</header>
|
</header>
|
||||||
<p class="mt-3 text-sm">%s</p>
|
<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]">
|
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5 border-t border-border/60 pt-3 text-xs">
|
||||||
<dt class="text-muted-foreground uppercase tracking-wider">Punisher</dt>
|
<dt class="text-muted-foreground">Punisher</dt>
|
||||||
<dd class="text-foreground/80">%s</dd>
|
<dd class="text-foreground/80">%s</dd>
|
||||||
<dt class="text-muted-foreground uppercase tracking-wider">Expires</dt>
|
<dt class="text-muted-foreground">Expires</dt>
|
||||||
<dd class="text-foreground/80">%s</dd>
|
<dd class="text-foreground/80">%s</dd>
|
||||||
%s
|
%s
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
""".formatted(searchBlob, typeName, status, accent, accent, typeName, statusChip, reason, escapeHtml(punisher), endDate, ipRow);
|
""".formatted(searchBlob, typeName, status, accent, accent, typeLabel, statusChip, reason, escapeHtml(punisher), endDate, ipRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String accentFor(PunishmentType type)
|
private static String accentFor(PunishmentType type)
|
||||||
@@ -196,4 +197,10 @@ public class PunishmentsUIEndpoint extends AbstractServlet
|
|||||||
.replace(">", ">")
|
.replace(">", ">")
|
||||||
.replace("\"", """);
|
.replace("\"", """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String titleCase(String s)
|
||||||
|
{
|
||||||
|
if (s == null || s.isEmpty()) return s;
|
||||||
|
return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public class SchematicUploadEndpoint extends AbstractServlet
|
|||||||
AuthenticatedUser user = currentStaff(request);
|
AuthenticatedUser user = currentStaff(request);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
return schematicsHTML(signInPrompt("to upload schematics"));
|
return schematicsHTML(signInPrompt(request, "to upload schematics"));
|
||||||
}
|
}
|
||||||
return readFile(this.getClass().getResourceAsStream("/httpd/schematic_upload.html"));
|
return readFile(this.getClass().getResourceAsStream("/httpd/schematic_upload.html"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ COMMANDS
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rise rise-1 mt-6 flex flex-wrap items-center gap-3">
|
<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">
|
<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>
|
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-search"/></svg>
|
||||||
<input id="command-filter"
|
<input id="command-filter"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -19,7 +19,7 @@ COMMANDS
|
|||||||
Collapse all
|
Collapse all
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<p class="rise rise-1 mt-2 hidden font-mono text-[11px] text-destructive" id="command-empty">No commands match that filter.</p>
|
<p class="rise rise-1 mt-2 hidden text-sm text-destructive" id="command-empty">No commands match that filter.</p>
|
||||||
|
|
||||||
<section class="rise rise-2 mt-2">
|
<section class="rise rise-2 mt-2">
|
||||||
${commands}
|
${commands}
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ server:
|
|||||||
file-path: "httpd.log" # relative to the module's data folder
|
file-path: "httpd.log" # relative to the module's data folder
|
||||||
console: false # also mirror to the Bukkit console
|
console: false # also mirror to the Bukkit console
|
||||||
|
|
||||||
|
# Jetty thread pool. Bounded so a flood of HTTP requests can't starve the
|
||||||
|
# Minecraft tick thread or consume unbounded memory.
|
||||||
|
threads:
|
||||||
|
max: 16
|
||||||
|
min: 2
|
||||||
|
idle-timeout-ms: 30000
|
||||||
|
|
||||||
|
# Per-connection limits that close slow/abusive clients quickly.
|
||||||
|
limits:
|
||||||
|
idle-timeout-ms: 15000 # drop conns with no progress for this long
|
||||||
|
accept-queue: 32 # OS-level backlog of pending TCP connects
|
||||||
|
request-header-bytes: 8192 # cap header size; oversized requests get 431
|
||||||
|
|
||||||
# Token-bucket rate limiting. Defaults are sized for a small sized server.
|
# Token-bucket rate limiting. Defaults are sized for a small sized server.
|
||||||
# capacity = burst, per-second = sustained rate. Disable globally with enabled: false.
|
# capacity = burst, per-second = sustained rate. Disable globally with enabled: false.
|
||||||
rate-limit:
|
rate-limit:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ Indefinite Bans
|
|||||||
INDEFBANS
|
INDEFBANS
|
||||||
<section class="rise flex flex-wrap items-end justify-between gap-3">
|
<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>
|
<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">
|
<div class="flex items-center gap-4 text-sm text-muted-foreground tabular">
|
||||||
<span><span class="text-foreground">${group_count}</span> groups</span>
|
<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_users}</span> users</span>
|
||||||
<span><span class="text-foreground">${total_uuids}</span> uuids</span>
|
<span><span class="text-foreground">${total_uuids}</span> uuids</span>
|
||||||
|
|||||||
@@ -2,26 +2,26 @@ Overview
|
|||||||
HOME
|
HOME
|
||||||
<section class="rise flex flex-wrap items-end justify-between gap-3">
|
<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>
|
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Overview</h1>
|
||||||
<span class="font-mono text-xs text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
Minecraft version <span data-stat="version">—</span>
|
Minecraft version <span data-stat="version" class="text-foreground">—</span>
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<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">
|
<article class="rise rise-1 ring-card relative flex flex-col overflow-hidden rounded-2xl bg-card p-5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">Players</span>
|
<span class="text-sm text-muted-foreground">Players</span>
|
||||||
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-users"/></svg>
|
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-users"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex items-baseline gap-2">
|
<div class="mt-4 flex items-baseline gap-2">
|
||||||
<span data-stat="players-online" class="tabular text-4xl font-medium tracking-tight">—</span>
|
<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>
|
<span class="text-sm text-muted-foreground">/ <span data-stat="players-max" class="tabular">—</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted">
|
<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 data-stat="players-bar" class="h-full rounded-full bg-primary transition-[width] duration-500" style="width:0"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex items-center justify-between font-mono text-[11px] text-muted-foreground">
|
<div class="mt-auto flex items-center justify-between pt-3 text-xs text-muted-foreground">
|
||||||
<span>online</span>
|
<span>online</span>
|
||||||
<a href="/players/" class="inline-flex items-center gap-1 text-foreground/80 hover:text-foreground">
|
<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>
|
view list <svg class="size-3" aria-hidden="true"><use href="#i-arrow-right"/></svg>
|
||||||
@@ -29,9 +29,9 @@ HOME
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="rise rise-2 ring-card relative overflow-hidden rounded-2xl bg-card p-5">
|
<article class="rise rise-2 ring-card relative flex flex-col overflow-hidden rounded-2xl bg-card p-5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">CPU</span>
|
<span class="text-sm text-muted-foreground">CPU</span>
|
||||||
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-chip"/></svg>
|
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-chip"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex items-baseline gap-1.5">
|
<div class="mt-4 flex items-baseline gap-1.5">
|
||||||
@@ -40,38 +40,38 @@ HOME
|
|||||||
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted">
|
<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 data-stat="cpu-bar" class="h-full rounded-full bg-primary transition-[width] duration-500" style="width:0"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex items-center justify-between font-mono text-[11px] text-muted-foreground">
|
<div class="mt-auto flex items-center justify-between pt-3 text-xs text-muted-foreground">
|
||||||
<span>process · <span data-stat="cpu-cores" class="tabular text-foreground/80">—</span> cores</span>
|
<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>
|
<span>sys <span data-stat="cpu-system-value" class="tabular text-foreground/80">—</span></span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="rise rise-3 ring-card relative overflow-hidden rounded-2xl bg-card p-5">
|
<article class="rise rise-3 ring-card relative flex flex-col overflow-hidden rounded-2xl bg-card p-5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">Memory</span>
|
<span class="text-sm text-muted-foreground">Memory</span>
|
||||||
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-database"/></svg>
|
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-database"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex items-baseline gap-1.5">
|
<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-value" class="tabular text-4xl font-medium tracking-tight">—</span>
|
||||||
<span data-stat="mem-unit" class="font-mono text-sm text-muted-foreground">—</span>
|
<span data-stat="mem-unit" class="text-sm text-muted-foreground">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 h-1 overflow-hidden rounded-full bg-muted">
|
<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 data-stat="mem-bar" class="h-full rounded-full bg-primary transition-[width] duration-500" style="width:0"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex items-center justify-between font-mono text-[11px] text-muted-foreground">
|
<div class="mt-auto flex items-center justify-between pt-3 text-xs text-muted-foreground">
|
||||||
<span>heap · <span data-stat="mem-percent" class="tabular text-foreground/80">—</span></span>
|
<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>
|
<span>max <span data-stat="mem-max" class="tabular text-foreground/80">—</span></span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="rise rise-4 ring-card relative overflow-hidden rounded-2xl bg-card p-5">
|
<article class="rise rise-4 ring-card relative flex flex-col overflow-hidden rounded-2xl bg-card p-5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">Ticks per second</span>
|
<span class="text-sm text-muted-foreground">Ticks per second</span>
|
||||||
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-chart"/></svg>
|
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-chart"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex items-baseline gap-1.5">
|
<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 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>
|
<span class="text-sm text-muted-foreground">/ 20.00</span>
|
||||||
</div>
|
</div>
|
||||||
<svg data-spark="tps" viewBox="0 0 600 60" preserveAspectRatio="none" class="mt-3 h-12 w-full overflow-visible">
|
<svg data-spark="tps" viewBox="0 0 600 60" preserveAspectRatio="none" class="mt-3 h-12 w-full overflow-visible">
|
||||||
<defs>
|
<defs>
|
||||||
@@ -83,7 +83,7 @@ HOME
|
|||||||
<polygon data-spark-area class="text-primary" fill="url(#spark-fill)" points=""/>
|
<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=""/>
|
<polyline data-spark-line fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" class="text-primary" points=""/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="mt-1 flex items-center justify-between font-mono text-[11px] text-muted-foreground">
|
<div class="mt-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>5m <span data-stat="tps-5m" class="tabular text-foreground/80">—</span></span>
|
<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>
|
<span>15m <span data-stat="tps-15m" class="tabular text-foreground/80">—</span></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,51 +93,51 @@ HOME
|
|||||||
|
|
||||||
<section class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<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">
|
<article class="rise rise-5 ring-card flex flex-col rounded-2xl bg-card p-5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">Uptime</span>
|
<span class="text-sm text-muted-foreground">Uptime</span>
|
||||||
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-clock"/></svg>
|
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-clock"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 font-mono text-2xl tracking-tight">
|
<div class="my-auto font-mono text-2xl tracking-tight">
|
||||||
<span data-stat="uptime">—</span>
|
<span data-stat="uptime">—</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="rise rise-5 ring-card rounded-2xl bg-card p-5">
|
<article class="rise rise-5 ring-card flex flex-col rounded-2xl bg-card p-5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">World</span>
|
<span class="text-sm text-muted-foreground">World</span>
|
||||||
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-package"/></svg>
|
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-package"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<dl class="mt-3 grid grid-cols-3 gap-2 font-mono text-sm">
|
<dl class="my-auto grid grid-cols-3 gap-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-[10px] uppercase tracking-wider text-muted-foreground">Worlds</dt>
|
<dt class="text-xs text-muted-foreground">Worlds</dt>
|
||||||
<dd data-stat="worlds" class="mt-1 tabular text-lg text-foreground">—</dd>
|
<dd data-stat="worlds" class="mt-1 tabular text-lg text-foreground">—</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-[10px] uppercase tracking-wider text-muted-foreground">Chunks</dt>
|
<dt class="text-xs text-muted-foreground">Chunks</dt>
|
||||||
<dd data-stat="chunks" class="mt-1 tabular text-lg text-foreground">—</dd>
|
<dd data-stat="chunks" class="mt-1 tabular text-lg text-foreground">—</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-[10px] uppercase tracking-wider text-muted-foreground">Entities</dt>
|
<dt class="text-xs text-muted-foreground">Entities</dt>
|
||||||
<dd data-stat="entities" class="mt-1 tabular text-lg text-foreground">—</dd>
|
<dd data-stat="entities" class="mt-1 tabular text-lg text-foreground">—</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="rise rise-6 ring-card rounded-2xl bg-card p-5">
|
<article class="rise rise-6 ring-card flex flex-col rounded-2xl bg-card p-5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">Plugins</span>
|
<span class="text-sm text-muted-foreground">Plugins</span>
|
||||||
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-code"/></svg>
|
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-code"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex items-baseline gap-2">
|
<div class="mt-3 flex items-baseline gap-2">
|
||||||
<span data-stat="plugins" class="tabular text-2xl font-medium tracking-tight">—</span>
|
<span data-stat="plugins" class="tabular text-2xl font-medium tracking-tight">—</span>
|
||||||
<span class="text-sm text-muted-foreground">active</span>
|
<span class="text-sm text-muted-foreground">active</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex gap-2">
|
<div class="mt-auto flex gap-2 pt-3">
|
||||||
<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">
|
<a href="/api/commands/" class="inline-flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs 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
|
<svg class="size-3" aria-hidden="true"><use href="#i-arrow-right"/></svg> commands
|
||||||
</a>
|
</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">
|
<a href="/api/schematics/download/" class="inline-flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs 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
|
<svg class="size-3" aria-hidden="true"><use href="#i-arrow-right"/></svg> schematics
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ Players
|
|||||||
PLAYERS
|
PLAYERS
|
||||||
<section class="rise flex flex-wrap items-end justify-between gap-3">
|
<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>
|
<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-sm text-muted-foreground tabular">
|
||||||
<span class="text-foreground">${player_count}</span> / <span>${player_max}</span> online
|
<span class="text-foreground">${player_count}</span> / <span>${player_max}</span> online
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rise rise-1 mt-6 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
<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">
|
<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>
|
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-search"/></svg>
|
||||||
<input id="player-filter"
|
<input id="player-filter"
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ PUNISHMENTS
|
|||||||
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Punishments</h1>
|
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">Punishments</h1>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rise rise-1 mt-6 max-w-2xl">
|
<section class="rise rise-1 mt-6">
|
||||||
<form onsubmit="event.preventDefault(); redirect();" class="flex flex-col gap-3 sm:flex-row">
|
<form onsubmit="event.preventDefault(); redirect();" class="flex flex-col 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">
|
<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>
|
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-search"/></svg>
|
||||||
<input id="uuid"
|
<input id="uuid"
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ PUNISHMENTS
|
|||||||
alt="" loading="lazy" width="48" height="48">
|
alt="" loading="lazy" width="48" height="48">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-medium tracking-tight md:text-4xl">${player_name}</h1>
|
<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>
|
<p class="mt-1 font-mono text-xs text-muted-foreground break-all">${player_uuid}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-mono text-xs text-muted-foreground tabular">
|
<span class="text-sm text-muted-foreground tabular">
|
||||||
<span class="text-foreground">${punishment_count}</span> ${punishment_label}
|
<span class="text-foreground">${punishment_count}</span> ${punishment_label}
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rise rise-1 mt-4 flex flex-wrap items-center gap-3">
|
<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">
|
<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>
|
<svg class="size-4 text-muted-foreground" aria-hidden="true"><use href="#i-search"/></svg>
|
||||||
<input id="punish-filter"
|
<input id="punish-filter"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -37,7 +37,7 @@ PUNISHMENTS
|
|||||||
${punishments}
|
${punishments}
|
||||||
</section>
|
</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>
|
<p id="punish-empty" class="rise rise-2 mt-4 hidden text-sm text-muted-foreground">No punishments match those filters.</p>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
@@ -57,18 +57,22 @@ ${punishments}
|
|||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'bg-card ring-card text-muted-foreground hover:bg-muted hover:text-foreground';
|
: 'bg-card ring-card text-muted-foreground hover:bg-muted hover:text-foreground';
|
||||||
return `<button type="button" data-group="${group}" data-value="${value}"
|
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>`;
|
class="inline-flex h-7 items-center rounded-full px-3 text-xs transition-colors ${cls}">${label}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(s) {
|
||||||
|
return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChips() {
|
function renderChips() {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
parts.push(chip('all', 'type', 'all', state.type === 'all'));
|
parts.push(chip('All', 'type', 'all', state.type === 'all'));
|
||||||
types.forEach(t => parts.push(chip(t.toLowerCase(), 'type', t, state.type === t)));
|
types.forEach(t => parts.push(chip(titleCase(t), 'type', t, state.type === t)));
|
||||||
if (hasStatus) {
|
if (hasStatus) {
|
||||||
parts.push('<span class="mx-1 h-4 w-px bg-border"></span>');
|
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('Any', 'status', 'all', state.status === 'all'));
|
||||||
parts.push(chip('active', 'status', 'active', state.status === 'active'));
|
parts.push(chip('Active', 'status', 'active', state.status === 'active'));
|
||||||
parts.push(chip('expired', 'status', 'expired', state.status === 'expired'));
|
parts.push(chip('Expired', 'status', 'expired', state.status === 'expired'));
|
||||||
}
|
}
|
||||||
chips.innerHTML = parts.join('');
|
chips.innerHTML = parts.join('');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ SCHEMATICS
|
|||||||
<table id="schemList" class="w-full text-sm">
|
<table id="schemList" class="w-full text-sm">
|
||||||
<thead class="border-b border-border/60 bg-muted/40">
|
<thead class="border-b border-border/60 bg-muted/40">
|
||||||
<tr>
|
<tr>
|
||||||
<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-left text-xs font-medium 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="px-4 py-2.5 text-right text-xs font-medium text-muted-foreground">Size</th>
|
||||||
<th scope="col" class="w-8"></th>
|
<th scope="col" class="w-8"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ SCHEMATICS
|
|||||||
<svg class="size-3.5" aria-hidden="true"><use href="#i-arrow-right"/></svg>
|
<svg class="size-3.5" aria-hidden="true"><use href="#i-arrow-right"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p id="picked-name" class="mt-3 font-mono text-[11px] text-muted-foreground"></p>
|
<p id="picked-name" class="mt-3 text-xs text-muted-foreground"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -5,18 +5,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>${TITLE} · Plex HTTPD</title>
|
<title>${TITLE} · 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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
@@ -25,7 +13,7 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
|
||||||
<style type="text/tailwindcss">
|
<style type="text/tailwindcss">
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (@media (prefers-color-scheme: dark));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
|
--font-sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
|
||||||
@@ -67,7 +55,8 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.dark {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
--color-background: oklch(0.145 0 0);
|
--color-background: oklch(0.145 0 0);
|
||||||
--color-foreground: oklch(0.985 0 0);
|
--color-foreground: oklch(0.985 0 0);
|
||||||
--color-card: oklch(0.205 0 0);
|
--color-card: oklch(0.205 0 0);
|
||||||
@@ -91,6 +80,7 @@
|
|||||||
--color-surface: oklch(0.2 0 0);
|
--color-surface: oklch(0.2 0 0);
|
||||||
--color-surface-foreground: oklch(0.708 0 0);
|
--color-surface-foreground: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -186,10 +176,6 @@
|
|||||||
|
|
||||||
/* Maia: subtle ring on cards, no shadow */
|
/* 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); }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-background text-foreground min-h-screen antialiased">
|
<body class="bg-background text-foreground min-h-screen antialiased">
|
||||||
@@ -291,13 +277,13 @@
|
|||||||
<div class="layer-content flex min-h-screen flex-col">
|
<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">
|
<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">
|
<div class="mx-auto flex h-14 max-w-7xl items-center gap-4 px-6">
|
||||||
<a href="/" class="flex items-center gap-2.5 text-foreground transition-opacity hover:opacity-80">
|
<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">
|
<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>
|
<span class="text-sm font-semibold tracking-tight">Plex HTTPD</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="nav-scroll flex flex-1 items-center gap-1 overflow-x-auto">
|
<nav class="hidden flex-1 items-center gap-1 md:flex">
|
||||||
<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="/">
|
<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>
|
<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
|
Overview
|
||||||
@@ -324,14 +310,48 @@
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex flex-1 items-center justify-end gap-2 md:flex-initial">
|
||||||
<div id="plex-auth" class="hidden md:flex items-center gap-2"></div>
|
<div data-plex-auth class="hidden items-center gap-2 md:flex"></div>
|
||||||
<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">
|
<button id="plex-nav-toggle" type="button" 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 md:hidden" aria-label="Toggle menu" aria-expanded="false" aria-controls="plex-mobile-menu">
|
||||||
<svg class="size-4 hidden dark:block" aria-hidden="true"><use href="#i-sun"/></svg>
|
<svg class="size-4 block" data-icon="open" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||||
<svg class="size-4 block dark:hidden" aria-hidden="true"><use href="#i-moon"/></svg>
|
<path d="M4 7h16M4 12h16M4 17h16"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="size-4 hidden" data-icon="close" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="plex-mobile-menu" class="hidden border-t border-border/60 md:hidden">
|
||||||
|
<nav class="mx-auto flex max-w-7xl flex-col gap-1 px-4 py-3">
|
||||||
|
<a class="nav-link ${ACTIVE_HOME} group flex h-10 items-center gap-2.5 rounded-xl 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-4 opacity-70 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 flex h-10 items-center gap-2.5 rounded-xl 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-4 opacity-70 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 flex h-10 items-center gap-2.5 rounded-xl 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-4 opacity-70 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 flex h-10 items-center gap-2.5 rounded-xl 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-4 opacity-70 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 flex h-10 items-center gap-2.5 rounded-xl 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-4 opacity-70 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 flex h-10 items-center gap-2.5 rounded-xl 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-4 opacity-70 group-data-[active=true]:text-primary group-data-[active=true]:opacity-100" aria-hidden="true"><use href="#i-package"/></svg>
|
||||||
|
Schematics
|
||||||
|
</a>
|
||||||
|
<div data-plex-auth class="mt-2 flex flex-col gap-2 border-t border-border/60 pt-3"></div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="mx-auto w-full max-w-7xl flex-1 px-6 py-10 md:py-14">
|
<main class="mx-auto w-full max-w-7xl flex-1 px-6 py-10 md:py-14">
|
||||||
@@ -341,28 +361,46 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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 => {
|
document.querySelectorAll('.nav-link').forEach(a => {
|
||||||
if (a.classList.contains('active')) a.setAttribute('data-active', 'true');
|
if (a.classList.contains('active')) a.setAttribute('data-active', 'true');
|
||||||
});
|
});
|
||||||
(function () {
|
(function () {
|
||||||
const mount = document.getElementById('plex-auth');
|
const toggle = document.getElementById('plex-nav-toggle');
|
||||||
if (!mount) return;
|
const menu = document.getElementById('plex-mobile-menu');
|
||||||
const linkClasses = 'ring-card inline-flex h-8 items-center gap-1.5 rounded-full bg-card px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground';
|
if (!toggle || !menu) return;
|
||||||
|
const setOpen = (open) => {
|
||||||
|
menu.classList.toggle('hidden', !open);
|
||||||
|
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
toggle.querySelector('[data-icon="open"]').classList.toggle('hidden', open);
|
||||||
|
toggle.querySelector('[data-icon="close"]').classList.toggle('hidden', !open);
|
||||||
|
};
|
||||||
|
toggle.addEventListener('click', () => setOpen(menu.classList.contains('hidden')));
|
||||||
|
window.matchMedia('(min-width: 768px)').addEventListener('change', e => { if (e.matches) setOpen(false); });
|
||||||
|
})();
|
||||||
|
(function () {
|
||||||
|
const mounts = document.querySelectorAll('[data-plex-auth]');
|
||||||
|
if (!mounts.length) return;
|
||||||
|
const inlineLink = 'ring-card inline-flex h-8 items-center gap-1.5 rounded-full bg-card px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground';
|
||||||
|
const blockLink = 'flex h-10 items-center gap-2.5 rounded-xl bg-card px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground';
|
||||||
const escape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
const escape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const here = window.location.pathname + window.location.search;
|
||||||
|
const loginHref = '/oauth2/login?return_to=' + encodeURIComponent(here);
|
||||||
fetch('/oauth2/me', { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
fetch('/oauth2/me', { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
||||||
.then(r => r.json().catch(() => ({})).then(j => ({ status: r.status, body: j })))
|
.then(r => r.json().catch(() => ({})).then(j => ({ status: r.status, body: j })))
|
||||||
.then(({ status, body }) => {
|
.then(({ status, body }) => {
|
||||||
if (body && body.authenticated === false && body.reason === 'disabled') return;
|
if (body && body.authenticated === false && body.reason === 'disabled') return;
|
||||||
|
mounts.forEach(mount => {
|
||||||
|
const block = mount.classList.contains('flex-col');
|
||||||
|
const linkClasses = block ? blockLink : inlineLink;
|
||||||
if (status === 200 && body.authenticated) {
|
if (status === 200 && body.authenticated) {
|
||||||
mount.innerHTML = '<span class="text-xs text-muted-foreground">' + escape(body.username) + '</span>'
|
const label = block
|
||||||
+ '<a href="/oauth2/logout" class="' + linkClasses + '">Sign out</a>';
|
? '<span class="px-3 text-xs text-muted-foreground">Signed in as ' + escape(body.username) + '</span>'
|
||||||
|
: '<span class="text-xs text-muted-foreground">' + escape(body.username) + '</span>';
|
||||||
|
mount.innerHTML = label + '<a href="/oauth2/logout" class="' + linkClasses + '">Sign out</a>';
|
||||||
} else {
|
} else {
|
||||||
mount.innerHTML = '<a href="/oauth2/login" class="' + linkClasses + '">Sign in</a>';
|
mount.innerHTML = '<a href="' + loginHref + '" class="' + linkClasses + '">Sign in</a>';
|
||||||
}
|
}
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user