mirror of
https://github.com/plexusorg/Module-HTTPD.git
synced 2026-06-04 09:06:54 +00:00
begin live inventory
This commit is contained in:
@@ -21,6 +21,8 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maven { url = uri("https://maven.enginehub.org/repo/") }
|
maven { url = uri("https://maven.enginehub.org/repo/") }
|
||||||
|
|
||||||
|
maven { url = uri("https://repo.codemc.io/repository/maven-public/") }
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -33,6 +35,7 @@ dependencies {
|
|||||||
plexLibrary("org.eclipse.jetty:jetty-server:12.1.9")
|
plexLibrary("org.eclipse.jetty:jetty-server:12.1.9")
|
||||||
plexLibrary("org.eclipse.jetty.ee10:jetty-ee10-servlet:12.1.9")
|
plexLibrary("org.eclipse.jetty.ee10:jetty-ee10-servlet:12.1.9")
|
||||||
plexLibrary("org.eclipse.jetty:jetty-proxy:12.1.9")
|
plexLibrary("org.eclipse.jetty:jetty-proxy:12.1.9")
|
||||||
|
plexLibrary("de.tr7zw:item-nbt-api:2.15.7")
|
||||||
implementation(platform("com.intellectualsites.bom:bom-newest:1.56")) // Ref: https://github.com/IntellectualSites/bom
|
implementation(platform("com.intellectualsites.bom:bom-newest:1.56")) // Ref: https://github.com/IntellectualSites/bom
|
||||||
compileOnly("com.fastasyncworldedit:FastAsyncWorldEdit-Core")
|
compileOnly("com.fastasyncworldedit:FastAsyncWorldEdit-Core")
|
||||||
implementation("commons-io:commons-io:2.22.0")
|
implementation("commons-io:commons-io:2.22.0")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import dev.plex.module.PlexModule;
|
|||||||
import dev.plex.ratelimit.RateLimitFilter;
|
import dev.plex.ratelimit.RateLimitFilter;
|
||||||
import dev.plex.request.AbstractServlet;
|
import dev.plex.request.AbstractServlet;
|
||||||
import dev.plex.request.PlayerActionServlet;
|
import dev.plex.request.PlayerActionServlet;
|
||||||
|
import dev.plex.request.PlayerInventoryStreamServlet;
|
||||||
import dev.plex.request.PlayersStreamServlet;
|
import dev.plex.request.PlayersStreamServlet;
|
||||||
import dev.plex.request.SchematicUploadServlet;
|
import dev.plex.request.SchematicUploadServlet;
|
||||||
import dev.plex.request.StaffPlayersStreamServlet;
|
import dev.plex.request.StaffPlayersStreamServlet;
|
||||||
@@ -99,6 +100,7 @@ public class HTTPDModule extends PlexModule
|
|||||||
|
|
||||||
StatsBroadcaster.get().start();
|
StatsBroadcaster.get().start();
|
||||||
PlayersBroadcaster.get().start();
|
PlayersBroadcaster.get().start();
|
||||||
|
PlayerInventoryBroadcaster.get().start();
|
||||||
|
|
||||||
new IndefBansEndpoint();
|
new IndefBansEndpoint();
|
||||||
new IndexEndpoint();
|
new IndexEndpoint();
|
||||||
@@ -118,6 +120,7 @@ public class HTTPDModule extends PlexModule
|
|||||||
HTTPDModule.context.addServlet(PlayersStreamServlet.class, "/api/players/stream");
|
HTTPDModule.context.addServlet(PlayersStreamServlet.class, "/api/players/stream");
|
||||||
HTTPDModule.context.addServlet(StaffPlayersStreamServlet.class, "/api/players/stream/staff");
|
HTTPDModule.context.addServlet(StaffPlayersStreamServlet.class, "/api/players/stream/staff");
|
||||||
HTTPDModule.context.addServlet(PlayerActionServlet.class, "/api/admin/action");
|
HTTPDModule.context.addServlet(PlayerActionServlet.class, "/api/admin/action");
|
||||||
|
HTTPDModule.context.addServlet(PlayerInventoryStreamServlet.class, "/api/player/inventory/stream");
|
||||||
|
|
||||||
ServletHolder uploadHolder = HTTPDModule.context.addServlet(SchematicUploadServlet.class, "/api/schematics/uploading");
|
ServletHolder uploadHolder = HTTPDModule.context.addServlet(SchematicUploadServlet.class, "/api/schematics/uploading");
|
||||||
|
|
||||||
@@ -167,6 +170,14 @@ public class HTTPDModule extends PlexModule
|
|||||||
t.printStackTrace();
|
t.printStackTrace();
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
|
{
|
||||||
|
PlayerInventoryBroadcaster.get().shutdown();
|
||||||
|
}
|
||||||
|
catch (Throwable t)
|
||||||
|
{
|
||||||
|
t.printStackTrace();
|
||||||
|
}
|
||||||
|
try
|
||||||
{
|
{
|
||||||
atomicServer.get().stop();
|
atomicServer.get().stop();
|
||||||
atomicServer.get().destroy();
|
atomicServer.get().destroy();
|
||||||
|
|||||||
@@ -174,16 +174,22 @@ public class AbstractServlet extends HttpServlet
|
|||||||
{
|
{
|
||||||
String base = HTTPDModule.template;
|
String base = HTTPDModule.template;
|
||||||
String page = readFileReal(filename);
|
String page = readFileReal(filename);
|
||||||
String[] info = page.split("\n", 3);
|
String[] info = page.split("\\r?\\n", 3);
|
||||||
base = base.replace("${TITLE}", info[0]);
|
String title = info.length > 0 ? info[0] : "";
|
||||||
base = base.replace("${ACTIVE_" + info[1] + "}", "active");
|
String activeKey = info.length > 1 ? info[1] : "";
|
||||||
|
String content = info.length > 2 ? info[2] : "";
|
||||||
|
base = base.replace("${TITLE}", title);
|
||||||
|
if (!activeKey.isEmpty())
|
||||||
|
{
|
||||||
|
base = base.replace("${ACTIVE_" + activeKey + "}", "active");
|
||||||
|
}
|
||||||
base = base.replace("${ACTIVE_HOME}", "");
|
base = base.replace("${ACTIVE_HOME}", "");
|
||||||
base = base.replace("${ACTIVE_PLAYERS}", "");
|
base = base.replace("${ACTIVE_PLAYERS}", "");
|
||||||
base = base.replace("${ACTIVE_INDEFBANS}", "");
|
base = base.replace("${ACTIVE_INDEFBANS}", "");
|
||||||
base = base.replace("${ACTIVE_COMMANDS}", "");
|
base = base.replace("${ACTIVE_COMMANDS}", "");
|
||||||
base = base.replace("${ACTIVE_PUNISHMENTS}", "");
|
base = base.replace("${ACTIVE_PUNISHMENTS}", "");
|
||||||
base = base.replace("${ACTIVE_SCHEMATICS}", "");
|
base = base.replace("${ACTIVE_SCHEMATICS}", "");
|
||||||
base = base.replace("${CONTENT}", info[2]);
|
base = base.replace("${CONTENT}", content);
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package dev.plex.request;
|
||||||
|
|
||||||
|
import dev.plex.logging.Log;
|
||||||
|
import dev.plex.request.impl.PlayerInventoryBroadcaster;
|
||||||
|
import jakarta.servlet.AsyncContext;
|
||||||
|
import jakarta.servlet.AsyncEvent;
|
||||||
|
import jakarta.servlet.AsyncListener;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServlet;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class PlayerInventoryStreamServlet extends HttpServlet
|
||||||
|
{
|
||||||
|
@Override
|
||||||
|
protected void doGet(HttpServletRequest request, HttpServletResponse response)
|
||||||
|
throws ServletException, IOException
|
||||||
|
{
|
||||||
|
if (AbstractServlet.currentStaff(request) == null)
|
||||||
|
{
|
||||||
|
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String uuidStr = request.getParameter("uuid");
|
||||||
|
if (uuidStr == null)
|
||||||
|
{
|
||||||
|
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final UUID uuid;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
uuid = UUID.fromString(uuidStr);
|
||||||
|
}
|
||||||
|
catch (IllegalArgumentException e)
|
||||||
|
{
|
||||||
|
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String ipAddress = request.getRemoteAddr();
|
||||||
|
if ("127.0.0.1".equals(ipAddress))
|
||||||
|
{
|
||||||
|
String forwarded = request.getHeader("X-FORWARDED-FOR");
|
||||||
|
if (forwarded != null) ipAddress = forwarded;
|
||||||
|
}
|
||||||
|
Log.log(ipAddress + " opened inventory stream for " + uuid);
|
||||||
|
|
||||||
|
PlayerInventoryBroadcaster broadcaster = PlayerInventoryBroadcaster.get();
|
||||||
|
if (broadcaster.atCapacity())
|
||||||
|
{
|
||||||
|
response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
|
||||||
|
response.setHeader("Retry-After", "30");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
response.setContentType("text/event-stream");
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
response.setHeader("Cache-Control", "no-cache, no-transform");
|
||||||
|
response.setHeader("Connection", "keep-alive");
|
||||||
|
response.setHeader("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
final AsyncContext ctx = request.startAsync();
|
||||||
|
ctx.setTimeout(0L);
|
||||||
|
ctx.addListener(new AsyncListener()
|
||||||
|
{
|
||||||
|
@Override public void onComplete(AsyncEvent event) { broadcaster.removeSubscriber(uuid, ctx); }
|
||||||
|
@Override public void onTimeout(AsyncEvent event) { broadcaster.removeSubscriber(uuid, ctx); }
|
||||||
|
@Override public void onError(AsyncEvent event) { broadcaster.removeSubscriber(uuid, ctx); }
|
||||||
|
@Override public void onStartAsync(AsyncEvent event) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
PrintWriter writer;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
writer = response.getWriter();
|
||||||
|
}
|
||||||
|
catch (IOException e)
|
||||||
|
{
|
||||||
|
ctx.complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!broadcaster.addSubscriber(uuid, ctx, writer))
|
||||||
|
{
|
||||||
|
response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
|
||||||
|
ctx.complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
writer.write("retry: 5000\n\n");
|
||||||
|
writer.write("data: ");
|
||||||
|
writer.write(broadcaster.currentPayload(uuid));
|
||||||
|
writer.write("\n\n");
|
||||||
|
writer.flush();
|
||||||
|
if (writer.checkError())
|
||||||
|
{
|
||||||
|
broadcaster.removeSubscriber(uuid, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Throwable t)
|
||||||
|
{
|
||||||
|
broadcaster.removeSubscriber(uuid, ctx);
|
||||||
|
try { ctx.complete(); } catch (Throwable ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,13 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class AssetsEndpoint extends AbstractServlet
|
public class AssetsEndpoint extends AbstractServlet
|
||||||
{
|
{
|
||||||
|
private static final Pattern TEXTURE_PATH = Pattern.compile("(item|block)/[a-z0-9_]+\\.png");
|
||||||
|
|
||||||
|
|
||||||
@GetMapping(endpoint = "/assets/dashboard.js")
|
@GetMapping(endpoint = "/assets/dashboard.js")
|
||||||
@MappingHeaders(headers = {"content-type;application/javascript; charset=utf-8", "cache-control;public, max-age=300"})
|
@MappingHeaders(headers = {"content-type;application/javascript; charset=utf-8", "cache-control;public, max-age=300"})
|
||||||
public String dashboardJs(HttpServletRequest request, HttpServletResponse response)
|
public String dashboardJs(HttpServletRequest request, HttpServletResponse response)
|
||||||
@@ -41,6 +45,27 @@ public class AssetsEndpoint extends AbstractServlet
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping(endpoint = "/assets/textures/")
|
||||||
|
@MappingHeaders(headers = {"content-type;image/png", "cache-control;public, max-age=86400"})
|
||||||
|
public String texture(HttpServletRequest request, HttpServletResponse response)
|
||||||
|
{
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
String prefix = "/assets/textures/";
|
||||||
|
if (!uri.startsWith(prefix))
|
||||||
|
{
|
||||||
|
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String resourcePath = uri.substring(prefix.length());
|
||||||
|
if (!TEXTURE_PATH.matcher(resourcePath).matches())
|
||||||
|
{
|
||||||
|
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
serveResource("/httpd/assets/textures/" + resourcePath, response);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static void serveResource(String classpathPath, HttpServletResponse response)
|
private static void serveResource(String classpathPath, HttpServletResponse response)
|
||||||
{
|
{
|
||||||
try (InputStream in = AssetsEndpoint.class.getResourceAsStream(classpathPath);
|
try (InputStream in = AssetsEndpoint.class.getResourceAsStream(classpathPath);
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
package dev.plex.request.impl;
|
||||||
|
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import dev.plex.HTTPDModule;
|
||||||
|
import dev.plex.Plex;
|
||||||
|
import dev.plex.util.PlexLog;
|
||||||
|
import jakarta.servlet.AsyncContext;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.enchantments.Enchantment;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.PlayerInventory;
|
||||||
|
import org.bukkit.inventory.meta.Damageable;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.bukkit.scheduler.BukkitTask;
|
||||||
|
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.TreeSet;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import de.tr7zw.changeme.nbtapi.NBT;
|
||||||
|
import de.tr7zw.changeme.nbtapi.iface.ReadableItemNBT;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.inventory.ItemFlag;
|
||||||
|
import org.bukkit.persistence.PersistentDataContainer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams a single player's live inventory + armor + offhand to staff SSE
|
||||||
|
* subscribers. Samples on the Bukkit main thread once per second; only
|
||||||
|
* touches UUIDs that have at least one subscriber so it stays free when
|
||||||
|
* nobody is watching anyone.
|
||||||
|
*/
|
||||||
|
public final class PlayerInventoryBroadcaster
|
||||||
|
{
|
||||||
|
private static final PlayerInventoryBroadcaster INSTANCE = new PlayerInventoryBroadcaster();
|
||||||
|
private static final long REFRESH_TICKS = 20L; // 1 second
|
||||||
|
private static final Map<String, Boolean> TEXTURE_EXISTS = new ConcurrentHashMap<>();
|
||||||
|
private static final Map<String, Map<String, String>> TEXTURE_RESOLVED = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public static PlayerInventoryBroadcaster get()
|
||||||
|
{
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Map<UUID, Set<Subscriber>> subscribers = new ConcurrentHashMap<>();
|
||||||
|
private final AtomicInteger subscriberCount = new AtomicInteger();
|
||||||
|
|
||||||
|
private ScheduledExecutorService executor;
|
||||||
|
private BukkitTask refreshTask;
|
||||||
|
private int maxConnections = 32;
|
||||||
|
|
||||||
|
private PlayerInventoryBroadcaster() {}
|
||||||
|
|
||||||
|
public synchronized void start()
|
||||||
|
{
|
||||||
|
if (executor != null) return;
|
||||||
|
|
||||||
|
maxConnections = HTTPDModule.moduleConfig.getInt("server.sse.max-connections", 32);
|
||||||
|
int threads = Math.max(1, HTTPDModule.moduleConfig.getInt("server.sse.threads", 2));
|
||||||
|
|
||||||
|
executor = Executors.newScheduledThreadPool(threads, r ->
|
||||||
|
{
|
||||||
|
Thread t = new Thread(r, "Plex-HTTPD-Inv-SSE");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
refreshTask = Bukkit.getScheduler().runTaskTimer(
|
||||||
|
Plex.get(), this::tick, 0L, REFRESH_TICKS);
|
||||||
|
}
|
||||||
|
catch (Throwable t)
|
||||||
|
{
|
||||||
|
PlexLog.debug("PlayerInventoryBroadcaster: could not register refresh task: " + t.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
NBT.preloadApi();
|
||||||
|
}
|
||||||
|
catch (Throwable t)
|
||||||
|
{
|
||||||
|
PlexLog.debug("PlayerInventoryBroadcaster: NBT-API preload failed: " + t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void shutdown()
|
||||||
|
{
|
||||||
|
if (refreshTask != null)
|
||||||
|
{
|
||||||
|
try { refreshTask.cancel(); } catch (Throwable ignored) {}
|
||||||
|
refreshTask = null;
|
||||||
|
}
|
||||||
|
if (executor != null)
|
||||||
|
{
|
||||||
|
executor.shutdownNow();
|
||||||
|
executor = null;
|
||||||
|
}
|
||||||
|
for (Set<Subscriber> set : subscribers.values())
|
||||||
|
{
|
||||||
|
for (Subscriber sub : set)
|
||||||
|
{
|
||||||
|
try { sub.ctx.complete(); } catch (Throwable ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subscribers.clear();
|
||||||
|
subscriberCount.set(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean atCapacity()
|
||||||
|
{
|
||||||
|
return subscriberCount.get() >= maxConnections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean addSubscriber(UUID uuid, AsyncContext ctx, PrintWriter writer)
|
||||||
|
{
|
||||||
|
if (subscriberCount.get() >= maxConnections) return false;
|
||||||
|
Subscriber sub = new Subscriber(ctx, writer);
|
||||||
|
Set<Subscriber> set = subscribers.computeIfAbsent(uuid, k -> ConcurrentHashMap.newKeySet());
|
||||||
|
if (set.add(sub))
|
||||||
|
{
|
||||||
|
subscriberCount.incrementAndGet();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeSubscriber(UUID uuid, AsyncContext ctx)
|
||||||
|
{
|
||||||
|
Set<Subscriber> set = subscribers.get(uuid);
|
||||||
|
if (set == null) return;
|
||||||
|
Subscriber match = null;
|
||||||
|
for (Subscriber sub : set)
|
||||||
|
{
|
||||||
|
if (sub.ctx == ctx) { match = sub; break; }
|
||||||
|
}
|
||||||
|
if (match != null && set.remove(match))
|
||||||
|
{
|
||||||
|
subscriberCount.decrementAndGet();
|
||||||
|
if (set.isEmpty()) subscribers.remove(uuid, set);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String currentPayload(UUID uuid)
|
||||||
|
{
|
||||||
|
Player p = Bukkit.getPlayer(uuid);
|
||||||
|
if (p == null) return "{\"online\":false}";
|
||||||
|
return buildPayload(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs on the Bukkit main thread.
|
||||||
|
private void tick()
|
||||||
|
{
|
||||||
|
if (subscribers.isEmpty()) return;
|
||||||
|
for (Map.Entry<UUID, Set<Subscriber>> entry : subscribers.entrySet())
|
||||||
|
{
|
||||||
|
Set<Subscriber> set = entry.getValue();
|
||||||
|
if (set.isEmpty()) continue;
|
||||||
|
UUID uuid = entry.getKey();
|
||||||
|
String json;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Player p = Bukkit.getPlayer(uuid);
|
||||||
|
json = (p == null) ? "{\"online\":false}" : buildPayload(p);
|
||||||
|
}
|
||||||
|
catch (Throwable t)
|
||||||
|
{
|
||||||
|
json = "{\"online\":false}";
|
||||||
|
}
|
||||||
|
final String frame = "data: " + json + "\n\n";
|
||||||
|
ScheduledExecutorService exec = executor;
|
||||||
|
if (exec == null) return;
|
||||||
|
for (Subscriber sub : set)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
exec.execute(() -> writeFrame(uuid, sub, frame));
|
||||||
|
}
|
||||||
|
catch (Throwable t)
|
||||||
|
{
|
||||||
|
drop(uuid, sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeFrame(UUID uuid, Subscriber sub, String frame)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sub.writer.write(frame);
|
||||||
|
sub.writer.flush();
|
||||||
|
if (sub.writer.checkError()) drop(uuid, sub);
|
||||||
|
}
|
||||||
|
catch (Throwable t)
|
||||||
|
{
|
||||||
|
drop(uuid, sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drop(UUID uuid, Subscriber sub)
|
||||||
|
{
|
||||||
|
Set<Subscriber> set = subscribers.get(uuid);
|
||||||
|
if (set != null && set.remove(sub))
|
||||||
|
{
|
||||||
|
subscriberCount.decrementAndGet();
|
||||||
|
if (set.isEmpty()) subscribers.remove(uuid, set);
|
||||||
|
}
|
||||||
|
try { sub.ctx.complete(); } catch (Throwable ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildPayload(Player p)
|
||||||
|
{
|
||||||
|
Map<String, Object> root = new LinkedHashMap<>();
|
||||||
|
root.put("online", true);
|
||||||
|
root.put("name", p.getName());
|
||||||
|
|
||||||
|
PlayerInventory inv = p.getInventory();
|
||||||
|
List<Map<String, Object>> hotbar = new ArrayList<>(9);
|
||||||
|
for (int i = 0; i < 9; i++) hotbar.add(serializeItem(inv.getItem(i)));
|
||||||
|
List<Map<String, Object>> storage = new ArrayList<>(27);
|
||||||
|
for (int i = 9; i < 36; i++) storage.add(serializeItem(inv.getItem(i)));
|
||||||
|
|
||||||
|
Map<String, Object> armor = new LinkedHashMap<>();
|
||||||
|
armor.put("helmet", serializeItem(inv.getHelmet()));
|
||||||
|
armor.put("chest", serializeItem(inv.getChestplate()));
|
||||||
|
armor.put("legs", serializeItem(inv.getLeggings()));
|
||||||
|
armor.put("boots", serializeItem(inv.getBoots()));
|
||||||
|
|
||||||
|
root.put("hotbar", hotbar);
|
||||||
|
root.put("storage", storage);
|
||||||
|
root.put("armor", armor);
|
||||||
|
root.put("offhand", serializeItem(inv.getItemInOffHand()));
|
||||||
|
|
||||||
|
return new GsonBuilder().serializeNulls().create().toJson(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> serializeItem(ItemStack item)
|
||||||
|
{
|
||||||
|
if (item == null || item.getType().isAir()) return null;
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
String type = item.getType().name();
|
||||||
|
m.put("type", type);
|
||||||
|
m.put("amount", item.getAmount());
|
||||||
|
|
||||||
|
Map<String, String> texture = resolveTextures(item.getType());
|
||||||
|
if (texture != null && !texture.isEmpty()) m.put("texture", texture);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
short maxDur = item.getType().getMaxDurability();
|
||||||
|
if (maxDur > 0)
|
||||||
|
{
|
||||||
|
m.put("maxDamage", (int) maxDur);
|
||||||
|
if (item.hasItemMeta() && item.getItemMeta() instanceof Damageable d)
|
||||||
|
{
|
||||||
|
m.put("damage", d.getDamage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Throwable ignored) {}
|
||||||
|
|
||||||
|
if (item.hasItemMeta())
|
||||||
|
{
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Component name = meta.displayName();
|
||||||
|
if (name != null) m.put("name", PlainTextComponentSerializer.plainText().serialize(name));
|
||||||
|
}
|
||||||
|
catch (Throwable ignored) {}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<Component> lore = meta.lore();
|
||||||
|
if (lore != null && !lore.isEmpty())
|
||||||
|
{
|
||||||
|
List<String> out = new ArrayList<>(lore.size());
|
||||||
|
for (Component c : lore)
|
||||||
|
{
|
||||||
|
out.add(PlainTextComponentSerializer.plainText().serialize(c));
|
||||||
|
}
|
||||||
|
m.put("lore", out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Throwable ignored) {}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Map<Enchantment, Integer> enchants = meta.getEnchants();
|
||||||
|
if (enchants != null && !enchants.isEmpty())
|
||||||
|
{
|
||||||
|
Map<String, Integer> out = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<Enchantment, Integer> e : enchants.entrySet())
|
||||||
|
{
|
||||||
|
out.put(e.getKey().getKey().getKey(), e.getValue());
|
||||||
|
}
|
||||||
|
m.put("enchants", out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Throwable ignored) {}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (meta.isUnbreakable()) m.put("unbreakable", true);
|
||||||
|
}
|
||||||
|
catch (Throwable ignored) {}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Set<ItemFlag> flags = meta.getItemFlags();
|
||||||
|
if (flags != null && !flags.isEmpty())
|
||||||
|
{
|
||||||
|
List<String> out = new ArrayList<>(flags.size());
|
||||||
|
for (ItemFlag f : flags) out.add(f.name());
|
||||||
|
m.put("flags", out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Throwable ignored) {}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PersistentDataContainer pdc = meta.getPersistentDataContainer();
|
||||||
|
Set<NamespacedKey> keys = pdc.getKeys();
|
||||||
|
if (!keys.isEmpty())
|
||||||
|
{
|
||||||
|
Set<String> out = new TreeSet<>();
|
||||||
|
for (NamespacedKey k : keys) out.add(k.toString());
|
||||||
|
m.put("pdcKeys", out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Throwable ignored) {}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Function<ReadableItemNBT, String> toSnbt = ReadableItemNBT::toString;
|
||||||
|
String snbt = NBT.get(item, toSnbt);
|
||||||
|
if (snbt != null && !snbt.isEmpty() && !"{}".equals(snbt))
|
||||||
|
{
|
||||||
|
m.put("nbt", snbt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Throwable ignored) {}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves textures for a Material. For blocks held in 3D form (no
|
||||||
|
* dedicated item sprite, but has block face textures) returns
|
||||||
|
* {@code {top, side}} so the client can render an isometric cube. Items
|
||||||
|
* with a dedicated item sprite — including blocks that render as 2D
|
||||||
|
* sprites in inventory like doors and signs — return {@code {flat}}.
|
||||||
|
* Variant blocks (slab, stairs, wall, fence, etc.) fall back to the
|
||||||
|
* parent block's textures when no dedicated texture exists, mirroring how
|
||||||
|
* Minecraft itself reuses the parent's faces. Results are cached per-material.
|
||||||
|
*/
|
||||||
|
private static Map<String, String> resolveTextures(Material material)
|
||||||
|
{
|
||||||
|
if (material == null) return null;
|
||||||
|
String key = material.name().toLowerCase();
|
||||||
|
Map<String, String> cached = TEXTURE_RESOLVED.get(key);
|
||||||
|
if (cached != null) return cached.isEmpty() ? null : cached;
|
||||||
|
|
||||||
|
Map<String, String> result = resolveTexturesForName(material, key);
|
||||||
|
|
||||||
|
if (result.isEmpty())
|
||||||
|
{
|
||||||
|
String base = stripVariantSuffix(key);
|
||||||
|
if (base != null)
|
||||||
|
{
|
||||||
|
// Stone-style variants reuse the base block (cobblestone_slab → cobblestone);
|
||||||
|
// wood variants reuse planks (oak_slab → oak_planks);
|
||||||
|
// brick variants use the plural form (stone_brick_slab → stone_bricks).
|
||||||
|
for (String candidate : List.of(base, base + "_planks", base + "s"))
|
||||||
|
{
|
||||||
|
result = resolveTexturesForName(material, candidate);
|
||||||
|
if (!result.isEmpty()) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEXTURE_RESOLVED.put(key, result);
|
||||||
|
return result.isEmpty() ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripVariantSuffix(String key)
|
||||||
|
{
|
||||||
|
String[] suffixes = {
|
||||||
|
"_slab", "_stairs", "_wall", "_fence_gate", "_fence",
|
||||||
|
"_pressure_plate", "_button"
|
||||||
|
};
|
||||||
|
for (String suffix : suffixes)
|
||||||
|
{
|
||||||
|
if (key.endsWith(suffix)) return key.substring(0, key.length() - suffix.length());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> resolveTexturesForName(Material material, String key)
|
||||||
|
{
|
||||||
|
Map<String, String> result = new LinkedHashMap<>();
|
||||||
|
boolean hasItemSprite = textureExists("item/" + key + ".png");
|
||||||
|
|
||||||
|
if (material.isBlock() && !hasItemSprite)
|
||||||
|
{
|
||||||
|
String top = pickFirstTexture(
|
||||||
|
"block/" + key + "_top.png",
|
||||||
|
"block/" + key + ".png",
|
||||||
|
"block/" + key + "_side.png",
|
||||||
|
"block/" + key + "_front.png");
|
||||||
|
String side = pickFirstTexture(
|
||||||
|
"block/" + key + "_side.png",
|
||||||
|
"block/" + key + ".png",
|
||||||
|
"block/" + key + "_front.png",
|
||||||
|
"block/" + key + "_top.png");
|
||||||
|
if (top != null)
|
||||||
|
{
|
||||||
|
result.put("top", "/assets/textures/" + top);
|
||||||
|
result.put("side", "/assets/textures/" + (side != null ? side : top));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.isEmpty())
|
||||||
|
{
|
||||||
|
String flat = pickFirstTexture(
|
||||||
|
"item/" + key + ".png",
|
||||||
|
"block/" + key + ".png",
|
||||||
|
"block/" + key + "_side.png",
|
||||||
|
"block/" + key + "_front.png",
|
||||||
|
"block/" + key + "_top.png");
|
||||||
|
if (flat != null) result.put("flat", "/assets/textures/" + flat);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pickFirstTexture(String... candidates)
|
||||||
|
{
|
||||||
|
for (String c : candidates)
|
||||||
|
{
|
||||||
|
if (textureExists(c)) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean textureExists(String relative)
|
||||||
|
{
|
||||||
|
return TEXTURE_EXISTS.computeIfAbsent(relative, p ->
|
||||||
|
PlayerInventoryBroadcaster.class.getResource("/httpd/assets/textures/" + p) != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Subscriber
|
||||||
|
{
|
||||||
|
final AsyncContext ctx;
|
||||||
|
final PrintWriter writer;
|
||||||
|
Subscriber(AsyncContext ctx, PrintWriter writer)
|
||||||
|
{
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.writer = writer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,36 @@
|
|||||||
const uuid = pingEl.getAttribute('data-uuid');
|
const uuid = pingEl.getAttribute('data-uuid');
|
||||||
if (!uuid) return;
|
if (!uuid) return;
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTitle(snake) {
|
||||||
|
if (!snake) return '';
|
||||||
|
return snake.toLowerCase().replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
|
||||||
|
function toRoman(n) {
|
||||||
|
return ROMAN[n] || String(n);
|
||||||
|
}
|
||||||
|
|
||||||
function pingColor(ping) {
|
function pingColor(ping) {
|
||||||
if (ping < 80) return 'text-success';
|
if (ping < 80) return 'text-success';
|
||||||
if (ping < 200) return 'text-warning';
|
if (ping < 200) return 'text-warning';
|
||||||
return 'text-destructive';
|
return 'text-destructive';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Live header (ping/world/gamemode/status) ----
|
||||||
|
|
||||||
function setOffline() {
|
function setOffline() {
|
||||||
pingEl.textContent = '—';
|
pingEl.textContent = '—';
|
||||||
pingEl.classList.remove('text-success', 'text-warning', 'text-destructive');
|
pingEl.classList.remove('text-success', 'text-warning', 'text-destructive');
|
||||||
@@ -45,16 +69,17 @@
|
|||||||
else setOffline();
|
else setOffline();
|
||||||
}
|
}
|
||||||
|
|
||||||
const es = new EventSource('/api/players/stream/staff');
|
const staffSrc = new EventSource('/api/players/stream/staff');
|
||||||
es.addEventListener('message', (evt) => {
|
staffSrc.addEventListener('message', (evt) => {
|
||||||
try { handle(JSON.parse(evt.data)); }
|
try { handle(JSON.parse(evt.data)); }
|
||||||
catch (e) {}
|
catch (e) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Action dialog wiring.
|
// ---- Action dialog wiring ----
|
||||||
|
|
||||||
const dialog = document.getElementById('action-dialog');
|
const dialog = document.getElementById('action-dialog');
|
||||||
const form = document.getElementById('action-form');
|
const form = document.getElementById('action-form');
|
||||||
if (!dialog || !form) return;
|
if (dialog && form) {
|
||||||
const actionInput = form.querySelector('[data-action-input]');
|
const actionInput = form.querySelector('[data-action-input]');
|
||||||
const actionLabel = form.querySelector('[data-action-label]');
|
const actionLabel = form.querySelector('[data-action-label]');
|
||||||
const durationField = form.querySelector('[data-duration-field]');
|
const durationField = form.querySelector('[data-duration-field]');
|
||||||
@@ -69,11 +94,8 @@
|
|||||||
durationField.hidden = !isTemp;
|
durationField.hidden = !isTemp;
|
||||||
durationField.querySelector('select').disabled = !isTemp;
|
durationField.querySelector('select').disabled = !isTemp;
|
||||||
if (reasonInput) reasonInput.value = '';
|
if (reasonInput) reasonInput.value = '';
|
||||||
if (typeof dialog.showModal === 'function') {
|
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||||
dialog.showModal();
|
else dialog.setAttribute('open', '');
|
||||||
} else {
|
|
||||||
dialog.setAttribute('open', '');
|
|
||||||
}
|
|
||||||
if (reasonInput) setTimeout(() => reasonInput.focus(), 0);
|
if (reasonInput) setTimeout(() => reasonInput.focus(), 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -81,4 +103,275 @@
|
|||||||
form.querySelectorAll('[data-dialog-cancel]').forEach(btn => {
|
form.querySelectorAll('[data-dialog-cancel]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => dialog.close());
|
btn.addEventListener('click', () => dialog.close());
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Live inventory ----
|
||||||
|
|
||||||
|
const invRoot = document.getElementById('inv-root');
|
||||||
|
if (!invRoot) return;
|
||||||
|
|
||||||
|
// Latest inventory snapshot, used by the click handler.
|
||||||
|
let lastInv = null;
|
||||||
|
// Slot currently rendered in the detail panel (key like "storage-5"); kept across re-renders so the highlight survives data refreshes.
|
||||||
|
let selectedKey = null;
|
||||||
|
|
||||||
|
function renderDurabilityBar(item) {
|
||||||
|
if (!item.maxDamage) return '';
|
||||||
|
const damage = item.damage || 0;
|
||||||
|
const remaining = (item.maxDamage - damage) / item.maxDamage;
|
||||||
|
if (remaining >= 0.999) return '';
|
||||||
|
const cls = remaining > 0.5 ? 'bg-success' : remaining > 0.25 ? 'bg-warning' : 'bg-destructive';
|
||||||
|
const pct = Math.max(0, Math.min(100, remaining * 100));
|
||||||
|
return `<div class="absolute inset-x-1 bottom-0.5 h-0.5 rounded-full bg-foreground/15">
|
||||||
|
<div class="${cls} h-full rounded-full" style="width:${pct.toFixed(1)}%"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tooltipFor(item) {
|
||||||
|
const parts = [];
|
||||||
|
parts.push(item.name || toTitle(item.type));
|
||||||
|
if (item.amount > 1) parts[0] += ' ×' + item.amount;
|
||||||
|
if (item.enchants) {
|
||||||
|
for (const [k, v] of Object.entries(item.enchants)) {
|
||||||
|
parts.push(toTitle(k) + ' ' + toRoman(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.maxDamage) {
|
||||||
|
const remaining = item.maxDamage - (item.damage || 0);
|
||||||
|
parts.push('Durability: ' + remaining + ' / ' + item.maxDamage);
|
||||||
|
}
|
||||||
|
return escapeHtml(parts.join(' • '));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItemIcon(item, large = false) {
|
||||||
|
const tex = item.texture || {};
|
||||||
|
if (tex.top) {
|
||||||
|
const side = tex.side || tex.top;
|
||||||
|
const sizeClass = large ? 'iso-cube--lg' : 'iso-cube--sm';
|
||||||
|
return `
|
||||||
|
<div class="iso-cube ${sizeClass} pointer-events-none">
|
||||||
|
<div class="iso-face iso-top" style="background-image:url(${tex.top})"></div>
|
||||||
|
<div class="iso-face iso-front" style="background-image:url(${side})"></div>
|
||||||
|
<div class="iso-face iso-right" style="background-image:url(${side})"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (tex.flat) {
|
||||||
|
return `<img src="${tex.flat}" alt="${escapeHtml(item.type)}" loading="lazy" class="size-full object-contain pointer-events-none">`;
|
||||||
|
}
|
||||||
|
return `<span class="absolute inset-0 grid place-items-center text-[8px] font-mono text-muted-foreground leading-tight px-0.5 text-center break-all pointer-events-none">${escapeHtml(item.type.toLowerCase().replace(/_/g, ' '))}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSlot(item, key) {
|
||||||
|
if (!item) {
|
||||||
|
return `<div class="ring-card size-12 rounded-md bg-muted/40" data-slot-key="${key}"></div>`;
|
||||||
|
}
|
||||||
|
const tooltip = tooltipFor(item);
|
||||||
|
const amount = item.amount > 1
|
||||||
|
? `<span class="pointer-events-none absolute bottom-0.5 right-1 text-xs font-mono font-medium [text-shadow:0_1px_2px_rgba(0,0,0,0.7)]">${item.amount}</span>`
|
||||||
|
: '';
|
||||||
|
const enchanted = item.enchants
|
||||||
|
? '<span class="pointer-events-none absolute inset-0 rounded-md ring-1 ring-inset ring-primary/40 bg-primary/5"></span>'
|
||||||
|
: '';
|
||||||
|
const selected = key === selectedKey
|
||||||
|
? 'ring-2 ring-primary'
|
||||||
|
: 'ring-card';
|
||||||
|
return `
|
||||||
|
<button type="button" data-slot-key="${key}"
|
||||||
|
class="${selected} relative size-12 rounded-md bg-muted/40 cursor-pointer transition-colors hover:bg-muted"
|
||||||
|
title="${tooltip}">
|
||||||
|
${renderItemIcon(item)}
|
||||||
|
${enchanted}
|
||||||
|
${amount}
|
||||||
|
${renderDurabilityBar(item)}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInventoryGrid(inv) {
|
||||||
|
const armor = inv.armor || {};
|
||||||
|
const storage = inv.storage || [];
|
||||||
|
const hotbar = inv.hotbar || [];
|
||||||
|
return `
|
||||||
|
<div class="flex flex-wrap gap-4 lg:flex-nowrap">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Main</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="grid grid-cols-9 gap-1">
|
||||||
|
${storage.map((s, i) => renderSlot(s, 'storage-' + i)).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-9 gap-1 border-t border-border/40 pt-2">
|
||||||
|
${hotbar.map((s, i) => renderSlot(s, 'hotbar-' + i)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Armor</p>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
${renderSlot(armor.helmet, 'armor-helmet')}
|
||||||
|
${renderSlot(armor.chest, 'armor-chest')}
|
||||||
|
${renderSlot(armor.legs, 'armor-legs')}
|
||||||
|
${renderSlot(armor.boots, 'armor-boots')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Offhand</p>
|
||||||
|
${renderSlot(inv.offhand, 'offhand')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetailPanel(item) {
|
||||||
|
if (!item) {
|
||||||
|
return `<div class="flex h-full min-h-[14rem] items-center justify-center text-center text-sm text-muted-foreground">
|
||||||
|
Click a slot to inspect the item.
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const safeType = escapeHtml(item.type);
|
||||||
|
const safeName = item.name ? escapeHtml(item.name) : null;
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`<div class="flex items-start gap-3">
|
||||||
|
<div class="ring-card relative size-16 shrink-0 rounded-md bg-muted/40">
|
||||||
|
${renderItemIcon(item, true)}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
${safeName ? `<p class="truncate text-base font-medium italic">${safeName}</p>` : ''}
|
||||||
|
<p class="font-mono text-xs text-muted-foreground break-all">${safeType}</p>
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">Count: ${item.amount}</p>
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
if (item.lore && item.lore.length) {
|
||||||
|
lines.push(`<div>
|
||||||
|
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Lore</p>
|
||||||
|
<ul class="mt-1 space-y-0.5 text-xs italic text-foreground/80">
|
||||||
|
${item.lore.map(l => `<li>${escapeHtml(l)}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.enchants && Object.keys(item.enchants).length) {
|
||||||
|
const rows = Object.entries(item.enchants)
|
||||||
|
.map(([k, v]) => `<li class="flex justify-between gap-3"><span>${escapeHtml(toTitle(k))}</span><span class="font-mono text-muted-foreground">${toRoman(v)}</span></li>`)
|
||||||
|
.join('');
|
||||||
|
lines.push(`<div>
|
||||||
|
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Enchantments</p>
|
||||||
|
<ul class="mt-1 space-y-0.5 text-xs">${rows}</ul>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.maxDamage) {
|
||||||
|
const remaining = item.maxDamage - (item.damage || 0);
|
||||||
|
const pct = Math.max(0, Math.min(100, (remaining / item.maxDamage) * 100));
|
||||||
|
const cls = pct > 50 ? 'bg-success' : pct > 25 ? 'bg-warning' : 'bg-destructive';
|
||||||
|
lines.push(`<div>
|
||||||
|
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Durability</p>
|
||||||
|
<div class="mt-1 flex items-center gap-2">
|
||||||
|
<div class="h-1.5 flex-1 rounded-full bg-muted">
|
||||||
|
<div class="${cls} h-full rounded-full" style="width:${pct.toFixed(1)}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="font-mono text-xs tabular text-muted-foreground">${remaining} / ${item.maxDamage}</span>
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = [];
|
||||||
|
if (item.unbreakable) tags.push('Unbreakable');
|
||||||
|
if (item.flags) item.flags.forEach(f => tags.push(toTitle(f.replace(/^HIDE_/, 'Hide '))));
|
||||||
|
if (tags.length) {
|
||||||
|
lines.push(`<div>
|
||||||
|
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Tags</p>
|
||||||
|
<div class="mt-1 flex flex-wrap gap-1">
|
||||||
|
${tags.map(t => `<span class="inline-flex h-5 items-center rounded-full bg-muted px-2 text-xs">${escapeHtml(t)}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.pdcKeys && item.pdcKeys.length) {
|
||||||
|
lines.push(`<div>
|
||||||
|
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Plugin NBT keys</p>
|
||||||
|
<ul class="mt-1 space-y-0.5 font-mono text-xs text-foreground/80">
|
||||||
|
${item.pdcKeys.map(k => `<li class="break-all">${escapeHtml(k)}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.nbt) {
|
||||||
|
lines.push(`<div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">NBT</p>
|
||||||
|
<button type="button" data-copy-nbt
|
||||||
|
class="rounded bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground">
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre data-nbt-text class="mt-1 max-h-48 overflow-auto rounded-md bg-muted/40 p-2 font-mono text-[10px] leading-snug whitespace-pre-wrap break-all">${escapeHtml(item.nbt)}</pre>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="space-y-4">${lines.join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemBySlotKey(inv, key) {
|
||||||
|
if (!inv || !inv.online || !key) return null;
|
||||||
|
if (key === 'offhand') return inv.offhand || null;
|
||||||
|
if (key.startsWith('storage-')) return (inv.storage || [])[parseInt(key.substring(8), 10)] || null;
|
||||||
|
if (key.startsWith('hotbar-')) return (inv.hotbar || [])[parseInt(key.substring(7), 10)] || null;
|
||||||
|
if (key.startsWith('armor-')) return (inv.armor || {})[key.substring(6)] || null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(inv) {
|
||||||
|
lastInv = inv;
|
||||||
|
if (!inv.online) {
|
||||||
|
selectedKey = null;
|
||||||
|
invRoot.innerHTML = `<p class="py-6 text-center text-sm text-muted-foreground">Player is offline.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
invRoot.innerHTML = `
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[auto_1fr]">
|
||||||
|
<div data-inv-grid>${renderInventoryGrid(inv)}</div>
|
||||||
|
<div data-inv-detail class="rounded-xl border border-border/40 bg-background/40 p-4">
|
||||||
|
${renderDetailPanel(getItemBySlotKey(inv, selectedKey))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
invRoot.addEventListener('click', (evt) => {
|
||||||
|
const copyBtn = evt.target.closest('[data-copy-nbt]');
|
||||||
|
if (copyBtn) {
|
||||||
|
const pre = copyBtn.closest('div').parentElement.querySelector('[data-nbt-text]');
|
||||||
|
if (pre && navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(pre.textContent).then(() => {
|
||||||
|
const original = copyBtn.textContent;
|
||||||
|
copyBtn.textContent = 'Copied';
|
||||||
|
setTimeout(() => { copyBtn.textContent = original; }, 1500);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
evt.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = evt.target.closest('[data-slot-key]');
|
||||||
|
if (!btn) return;
|
||||||
|
selectedKey = btn.getAttribute('data-slot-key');
|
||||||
|
const item = getItemBySlotKey(lastInv, selectedKey);
|
||||||
|
const detail = invRoot.querySelector('[data-inv-detail]');
|
||||||
|
if (detail) detail.innerHTML = renderDetailPanel(item);
|
||||||
|
invRoot.querySelectorAll('[data-slot-key]').forEach(el => {
|
||||||
|
const isSelected = el.getAttribute('data-slot-key') === selectedKey;
|
||||||
|
el.classList.toggle('ring-2', isSelected);
|
||||||
|
el.classList.toggle('ring-primary', isSelected);
|
||||||
|
el.classList.toggle('ring-card', !isSelected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const invSrc = new EventSource('/api/player/inventory/stream?uuid=' + encodeURIComponent(uuid));
|
||||||
|
invSrc.addEventListener('message', (evt) => {
|
||||||
|
try { render(JSON.parse(evt.data)); }
|
||||||
|
catch (e) {}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -90,6 +90,37 @@ PLAYERS
|
|||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.iso-cube {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transform: rotateX(-30deg) rotateY(45deg);
|
||||||
|
}
|
||||||
|
.iso-cube--sm { width: 2.1rem; height: 2.1rem; --iso-half: 1.05rem; }
|
||||||
|
.iso-cube--lg { width: 2.8rem; height: 2.8rem; --iso-half: 1.4rem; }
|
||||||
|
.iso-face {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
.iso-face.iso-top { transform: rotateX(90deg) translateZ(var(--iso-half)); filter: brightness(1.0); }
|
||||||
|
.iso-face.iso-front { transform: translateZ(var(--iso-half)); filter: brightness(0.82); }
|
||||||
|
.iso-face.iso-right { transform: rotateY(90deg) translateZ(var(--iso-half)); filter: brightness(0.66); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section class="rise rise-2 mt-4">
|
||||||
|
<article class="ring-card rounded-2xl bg-card p-5">
|
||||||
|
<h2 class="text-sm font-medium tracking-tight">Live inventory</h2>
|
||||||
|
<div id="inv-root" class="mt-4 text-sm text-muted-foreground">Waiting for data…</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
<dialog id="action-dialog"
|
<dialog id="action-dialog"
|
||||||
class="ring-card w-[min(28rem,calc(100%-2rem))] rounded-2xl bg-card p-5 text-foreground shadow-2xl backdrop:bg-background/60 backdrop:backdrop-blur-sm">
|
class="ring-card w-[min(28rem,calc(100%-2rem))] rounded-2xl bg-card p-5 text-foreground shadow-2xl backdrop:bg-background/60 backdrop:backdrop-blur-sm">
|
||||||
<form method="POST" action="/api/admin/action" id="action-form" class="flex flex-col gap-4">
|
<form method="POST" action="/api/admin/action" id="action-form" class="flex flex-col gap-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user