DevBlog №4 | массовый фикс JFX и фиксы связанные с самим лаунчером, добавление админ меню

This commit is contained in:
SashegDev
2026-07-01 19:36:52 +00:00
parent e49e630afe
commit 0d61ad1107
12 changed files with 1915 additions and 99 deletions
@@ -85,7 +85,11 @@ public class AuthManager {
public static boolean tryAutoLogin() {
if (isLoggedIn()) return true;
if (!Files.exists(AUTH_FILE)) return false;
return loadSavedSession();
boolean loaded = loadSavedSession();
if (loaded) {
refreshUserInfo();
}
return loaded;
}
public static AuthResult login(String username, String password) {
@@ -28,15 +28,33 @@ import java.util.concurrent.atomic.AtomicInteger;
public class PackDownloader {
@FunctionalInterface
public interface ProgressCallback {
void onProgress(String label, int percent, String stageName, int stageIndex, int stageCount);
}
private final Instance instance;
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
private final HttpClient httpClient = HttpClient.newHttpClient();
private ProgressCallback progressCallback;
//private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
public PackDownloader(Instance instance) {
this.instance = instance;
}
public void setProgressCallback(ProgressCallback callback) {
this.progressCallback = callback;
}
private void reportProgress(String label, int percent, String stageName, int stageIndex, int stageCount) {
if (progressCallback != null) {
try {
progressCallback.onProgress(label, percent, stageName, stageIndex, stageCount);
} catch (Exception ignored) {}
}
}
/**
* Get list of available packs from server
*/
@@ -140,6 +158,7 @@ public class PackDownloader {
*/
public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception {
LauncherLogger.info("Installing pack " + packName + " from server...");
reportProgress("Fetching manifest...", 0, "manifest", 0, 5);
// 1. Get manifest
PackManifest manifest = getPackManifest(packName);
@@ -157,24 +176,28 @@ public class PackDownloader {
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Fabric"));
reportProgress("Failed to install Fabric", 0, "error", 0, 1);
return false;
}
} else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install NeoForge"));
reportProgress("Failed to install NeoForge", 0, "error", 0, 1);
return false;
}
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Forge"));
reportProgress("Failed to install Forge", 0, "error", 0, 1);
return false;
}
} else {
boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft"));
reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1);
return false;
}
}
@@ -182,12 +205,15 @@ public class PackDownloader {
System.out.println(ZAnsi.green("Minecraft already installed, skipping..."));
}
reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5);
// 3. Scan local files only if there are files to download
Map<String, String> localFiles = scanLocalFiles();
// If pack has no files (vanilla/loader only), skip diff
if (manifest.files == null || manifest.files.isEmpty()) {
System.out.println(ZAnsi.green("Pack contains no additional files"));
reportProgress("Installing...", 50, "Installing pack files", 4, 5);
// Update instance metadata
instance.setServerPack(true);
@@ -198,15 +224,18 @@ public class PackDownloader {
instance.setLoaderVersion(manifest.getLoaderVersion());
instance.setAssetIndex(manifest.getAssetIndex());
reportProgress("Pack installed successfully!", 100, "Installing pack files", 4, 5);
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
return true;
}
// 4. Send diff request
System.out.println(ZAnsi.cyan("Checking pack files..."));
reportProgress("Checking pack files...", 40, "Installing pack files", 4, 5);
DiffResponse diff = getDiff(packName, localFiles);
// 5. Apply changes
reportProgress("Downloading pack files...", 50, "Installing pack files", 4, 5);
boolean success = applyDiff(diff, packName);
if (success) {
@@ -445,6 +474,8 @@ public class PackDownloader {
downloaded.incrementAndGet();
if (total > 0) {
ProgressBar.show("Download", downloaded.get(), total, "files");
int pct = 50 + (downloaded.get() * 45 / total);
reportProgress("Downloading " + downloaded.get() + "/" + total + " files...", pct, "Installing pack files", 4, 5);
}
} catch (Exception e) {
@@ -7,12 +7,14 @@ import org.json.JSONArray;
import org.json.JSONObject;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -20,6 +22,8 @@ import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
public class VersionInstaller {
@@ -58,25 +62,36 @@ public class VersionInstaller {
public String install(String versionId) throws Exception {
System.out.println(ZAnsi.cyan("Full install of Minecraft " + versionId + "..."));
ProgressBar.setStage("Version", 0, 5);
Path versionDir = minecraftDir.resolve("versions").resolve(versionId);
Files.createDirectories(versionDir);
String versionUrl = getVersionUrl(versionId);
if (versionUrl == null) throw new Exception("Version " + versionId + " not found");
ProgressBar.show("Fetching version info", 0, 1, "files");
String versionJson = downloadString(versionUrl);
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
ProgressBar.show("Version info", 1, 1, "files");
JSONObject versionData = new JSONObject(versionJson);
// client.jar
ProgressBar.show("Downloading client.jar", 0, 1, "files");
downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
versionDir.resolve(versionId + ".jar"), "client.jar");
ProgressBar.show("Client.jar", 1, 1, "files");
// Libraries
ProgressBar.setStage("Libraries", 1, 5);
System.out.println(ZAnsi.cyan("Downloading libraries..."));
downloadLibraries(versionData.getJSONArray("libraries"));
// Natives
ProgressBar.setStage("Natives", 2, 5);
System.out.println(ZAnsi.cyan("Extracting natives..."));
extractNatives(versionData.getJSONArray("libraries"));
String assetIndex;
if (versionData.has("assetIndex")) {
assetIndex = versionData.getJSONObject("assetIndex").getString("id");
@@ -87,13 +102,88 @@ public class VersionInstaller {
System.out.println(ZAnsi.cyan("Asset index: " + assetIndex));
// Download assets using correct index
ProgressBar.setStage("Assets", 3, 5);
System.out.println(ZAnsi.cyan("Downloading assets..."));
downloadAssets(versionData, assetIndex);
ProgressBar.setStage("Done", 4, 5);
System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " fully installed!"));
return assetIndex;
}
private void extractNatives(JSONArray libraries) throws Exception {
Path nativesDir = minecraftDir.resolve("natives");
Files.createDirectories(nativesDir);
// Determine OS classifier
String osName = System.getProperty("os.name").toLowerCase();
String classifier;
if (osName.contains("win")) {
classifier = "natives-windows";
} else if (osName.contains("mac")) {
classifier = "natives-macos";
} else {
classifier = "natives-linux";
}
int total = libraries.length();
int extracted = 0;
int failed = 0;
for (int i = 0; i < total; i++) {
JSONObject lib = libraries.getJSONObject(i);
if (!lib.has("downloads") || !lib.getJSONObject("downloads").has("classifiers")) continue;
JSONObject classifiers = lib.getJSONObject("downloads").getJSONObject("classifiers");
if (!classifiers.has(classifier)) continue;
JSONObject nativeArtifact = classifiers.getJSONObject(classifier);
String url = nativeArtifact.getString("url");
String path = nativeArtifact.getString("path");
Path libJar = minecraftDir.resolve("libraries").resolve(path);
// Download native library if not already present
if (!Files.exists(libJar)) {
try {
Files.createDirectories(libJar.getParent());
downloadFile(url, libJar, "");
} catch (Exception e) {
failed++;
continue;
}
}
// Extract .so/.dll/.dylib from the native jar
try (JarFile jar = new JarFile(libJar.toFile())) {
var entries = jar.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String name = entry.getName();
if (name.endsWith(".so") || name.endsWith(".dll") || name.endsWith(".dylib") || name.endsWith(".jnilib")) {
Path outFile = nativesDir.resolve(name);
if (!entry.isDirectory()) {
Files.createDirectories(outFile.getParent());
try (InputStream is = jar.getInputStream(entry)) {
Files.copy(is, outFile, StandardCopyOption.REPLACE_EXISTING);
}
}
}
}
} catch (Exception e) {
failed++;
}
extracted++;
ProgressBar.show("Natives", extracted, total, "files");
}
if (extracted > 0) {
ProgressBar.finish("Natives extracted (" + extracted + " ok, " + failed + " failed)");
} else {
System.out.println(ZAnsi.yellow("No natives to extract for " + classifier));
}
}
private void downloadLibraries(JSONArray libraries) throws Exception {
int total = libraries.length();
int count = 0;
@@ -19,6 +19,7 @@ import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.Version;
import java.awt.Desktop;
import java.io.BufferedReader;
@@ -29,6 +30,8 @@ import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.net.URL;
import java.net.URLDecoder;
import java.util.Base64;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.net.InetSocketAddress;
@@ -192,8 +195,27 @@ public class JFXLauncher extends Application {
private static void extractAssets() {
try {
Path assetsDir = Paths.get("assets");
String currentVersion = Version.getCurrentVersion();
Path versionFile = assetsDir.resolve(".version");
// Check if assets already match current version
if (Files.exists(assetsDir) && Files.exists(versionFile)) {
try {
String existingVersion = Files.readString(versionFile).trim();
if (existingVersion.equals(currentVersion)) {
System.out.println("[JFX] Assets up to date (v" + currentVersion + ")");
return;
}
} catch (Exception ignored) {}
}
// Clean old assets
if (Files.exists(assetsDir)) {
return;
try {
Files.walk(assetsDir)
.sorted(java.util.Comparator.reverseOrder())
.forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignored) {} });
} catch (Exception ignored) {}
}
String serverVersion = getServerVersion();
@@ -201,12 +223,14 @@ public class JFXLauncher extends Application {
System.out.println("[JFX] Loading assets via meta for version " + serverVersion);
if (downloadAssetsFromMeta(serverVersion)) {
System.out.println("[JFX] Assets loaded via meta");
Files.writeString(versionFile, currentVersion);
return;
}
System.out.println("[JFX] Meta unavailable, using fallback");
}
System.out.println("[JFX] Extracting assets from JAR...");
Files.createDirectories(assetsDir);
Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) {
try (JarFile jar = new JarFile(jarPath.toFile())) {
@@ -220,13 +244,14 @@ public class JFXLauncher extends Application {
} else {
Files.createDirectories(outPath.getParent());
try (InputStream is = jar.getInputStream(entry)) {
Files.copy(is, outPath);
Files.copy(is, outPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
}
}
}
System.out.println("[JFX] Assets extracted from JAR");
Files.writeString(versionFile, currentVersion);
}
} catch (Exception e) {
System.out.println("[JFX] Error extracting assets: " + e.getMessage());
@@ -420,6 +445,7 @@ public class JFXLauncher extends Application {
LaunchService.killAllProcesses();
if (server != null) server.stop(0);
Platform.exit();
System.exit(0);
});
} catch (Exception e) {
@@ -472,8 +498,13 @@ public class JFXLauncher extends Application {
server.createContext("/api/friends/accept", this::handleFriendAccept);
server.createContext("/api/friends/requests", this::handleFriendRequests);
server.createContext("/api/friends/status", this::handleFriendStatus);
server.createContext("/api/check-updates", this::handleCheckUpdates);
server.createContext("/api/update", this::handleUpdate);
server.createContext("/api/playtime/sync", this::handlePlaytimeSync);
server.createContext("/api/playtime/stats", this::handlePlaytimeStats);
server.createContext("/api/whitelist/mods", this::handleWhitelistMods);
server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod);
server.createContext("/api/admin", this::handleAdmin);
server.createContext("/assets/", this::handleStatic);
server.setExecutor(Executors.newCachedThreadPool());
@@ -711,8 +742,11 @@ public class JFXLauncher extends Application {
boolean success = false;
if ("zernmc".equalsIgnoreCase(loader)) {
setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 1, 4);
setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 0, 5);
PackDownloader downloader = new PackDownloader(instance);
downloader.setProgressCallback((label, pct, stageName, stageIdx, stageCount) -> {
setInstallProgressWithStage(label, pct, 100, stageName, stageIdx, stageCount);
});
var packs = downloader.getAvailablePacks();
ServerPack pack = null;
for (ServerPack p : packs) {
@@ -722,17 +756,23 @@ public class JFXLauncher extends Application {
}
}
if (pack != null) {
setInstallProgressWithStage("Downloading pack files...", 30, 100, "Downloading", 2, 4);
success = downloader.installOrUpdatePack(version, pack);
try {
success = downloader.installOrUpdatePack(version, pack);
} catch (Exception e) {
log("Pack install error: " + e.getMessage());
throw e;
}
if (success) {
setInstallProgressWithStage("Finalizing...", 90, 100, "Finalizing", 3, 4);
setInstallProgressWithStage("Finalizing...", 95, 100, "Finalizing", 4, 5);
} else {
log("Install error: pack install returned false");
}
} else {
log("Install error: pack not found on server: " + version + " (available: " + (packs != null ? packs.size() : 0) + ")");
}
} else {
MinecraftLib lib = new MinecraftLib(instance);
setInstallProgressWithStage("Installing Minecraft...", 20, 100, "Installing Minecraft", 1, 3);
setInstallProgressWithStage("Installing Minecraft...", 20, 100, "Installing Minecraft", 0, 3);
if ("vanilla".equalsIgnoreCase(loader)) {
success = lib.installMinecraft(version);
} else {
@@ -741,7 +781,7 @@ public class JFXLauncher extends Application {
}
setInstallProgressWithStage("Finalizing...", 90, 100, "Finalizing", 2, 3);
}
setInstallInProgress(false);
if (success) {
setInstallProgressWithStage("Installation complete!", 100, 100, "Done", 0, 1);
@@ -1365,6 +1405,233 @@ public class JFXLauncher extends Application {
}
}
private void handleCheckUpdates(HttpExchange exchange) {
try {
Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery());
String rawName = params.get("name");
if (rawName == null || rawName.isBlank()) {
sendJson(exchange, Map.of("success", false, "error", "Missing 'name' parameter"));
return;
}
String name = URLDecoder.decode(rawName, StandardCharsets.UTF_8);
Instance instance = InstanceManager.getInstance(name);
if (instance == null) {
sendJson(exchange, Map.of("success", false, "error", "Instance not found"));
return;
}
if (!instance.isServerPack()) {
sendJson(exchange, Map.of("success", false, "error", "Not a server pack"));
return;
}
PackDownloader downloader = new PackDownloader(instance);
String packName = instance.getServerPackName();
int serverVersion = downloader.getPackManifest(packName).getVersion();
int localVersion = instance.getServerVersion();
sendJson(exchange, Map.of(
"success", true,
"data", Map.of(
"hasUpdate", serverVersion > localVersion,
"serverVersion", serverVersion,
"localVersion", localVersion
)
));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleUpdate(HttpExchange exchange) {
try {
Map<String, String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
if (name == null || name.isBlank()) {
sendJson(exchange, Map.of("success", false, "error", "Missing 'name'"));
return;
}
Instance instance = InstanceManager.getInstance(name);
if (instance == null) {
sendJson(exchange, Map.of("success", false, "error", "Instance not found"));
return;
}
if (!instance.isServerPack()) {
sendJson(exchange, Map.of("success", false, "error", "Not a server pack"));
return;
}
PackDownloader downloader = new PackDownloader(instance);
boolean success = downloader.updatePack(instance.getServerPackName());
if (success) {
sendJson(exchange, Map.of("success", true, "data", Map.of("version", instance.getServerVersion())));
} else {
sendJson(exchange, Map.of("success", false, "error", "Update failed"));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleWhitelistMods(HttpExchange exchange) {
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
String response = ZHttpClient.get("/whitelist/mods");
List<Map<String, Object>> mods = new ArrayList<>();
for (String line : response.split("\n")) {
line = line.trim();
if (line.isEmpty()) continue;
String[] parts = line.split("::");
if (parts.length >= 3) {
Map<String, Object> mod = new HashMap<>();
mod.put("name", parts[0].trim());
mod.put("size", Long.parseLong(parts[1].trim()));
mod.put("hash", parts[2].trim());
mods.add(mod);
}
}
sendJson(exchange, Map.of("success", true, "data", mods));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleWhitelistInstallMod(HttpExchange exchange) {
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
Map<String, String> body = parseJson(exchange.getRequestBody());
String instanceName = body.get("instance");
String modName = body.get("modName");
String modHash = body.get("modHash");
if (instanceName == null || modName == null || modHash == null) {
sendJson(exchange, Map.of("success", false, "error", "Missing parameters"));
return;
}
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
sendJson(exchange, Map.of("success", false, "error", "Instance not found"));
return;
}
Path modsDir = instance.getPath().resolve("mods");
Files.createDirectories(modsDir);
Path targetFile = modsDir.resolve(modName);
// Download mod from server
String url = ZHttpClient.getBaseUrl() + "/whitelist/mods/" + modHash + "/" + modName;
java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(url))
.timeout(java.time.Duration.ofMinutes(5))
.header("Authorization", "Bearer " + AuthManager.getAccessToken())
.GET()
.build();
java.net.http.HttpResponse<Path> resp = java.net.http.HttpClient.newHttpClient()
.send(request, java.net.http.HttpResponse.BodyHandlers.ofFile(targetFile));
if (resp.statusCode() != 200) {
Files.deleteIfExists(targetFile);
sendJson(exchange, Map.of("success", false, "error", "HTTP " + resp.statusCode()));
return;
}
sendJson(exchange, Map.of("success", true, "message", modName + " installed"));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
// ====================== ADMIN ======================
private void handleAdmin(HttpExchange exchange) {
try {
String path = exchange.getRequestURI().getPath();
String method = exchange.getRequestMethod();
byte[] reqBodyBytes = exchange.getRequestBody().readAllBytes();
String body = reqBodyBytes.length > 0 ? new String(reqBodyBytes, StandardCharsets.UTF_8) : null;
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
String remotePath = path.replace("/api/admin", "/admin");
// Special handling for mod upload (base64 → multipart)
if ("POST".equals(method) && remotePath.equals("/admin/whitelist/mods") && body != null) {
handleAdminModUpload(exchange, body);
return;
}
String apiMethod = method;
// Java HttpURLConnection doesn't support DELETE directly in remoteApiCall,
// so map DELETE to POST for the Python server
if ("DELETE".equals(method)) {
apiMethod = "POST";
remotePath = remotePath + "/delete";
}
String response = remoteApiCall(apiMethod, remotePath, body);
sendRawJson(exchange, response);
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleAdminModUpload(HttpExchange exchange, String jsonBody) throws Exception {
@SuppressWarnings("unchecked")
Map<String, String> data = gson.fromJson(jsonBody, Map.class);
String filename = data.get("filename");
String base64Data = data.get("data");
if (filename == null || base64Data == null) {
sendJson(exchange, Map.of("success", false, "error", "Missing filename or data"));
return;
}
// Decode base64
byte[] fileBytes = Base64.getDecoder().decode(base64Data);
// Forward to Python API as multipart request
String boundary = "Boundary-" + System.currentTimeMillis();
URL url = new URL(LAUNCHER_SERVER + "/admin/whitelist/mods");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
String token = AuthManager.getAccessToken();
if (token != null && !token.isEmpty() && !token.equals("0")) {
conn.setRequestProperty("Authorization", "Bearer " + token);
}
conn.setConnectTimeout(30000);
conn.setReadTimeout(60000);
String header = "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\"\r\n"
+ "Content-Type: application/java-archive\r\n\r\n";
String footer = "\r\n--" + boundary + "--\r\n";
try (OutputStream os = conn.getOutputStream()) {
os.write(header.getBytes(StandardCharsets.UTF_8));
os.write(fileBytes);
os.write(footer.getBytes(StandardCharsets.UTF_8));
}
int code = conn.getResponseCode();
String respBody;
try (InputStream is = code >= 400 ? conn.getErrorStream() : conn.getInputStream()) {
respBody = new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
if (code >= 400) {
sendJson(exchange, Map.of("success", false, "error", "Upload failed: HTTP " + code + " - " + respBody));
} else {
sendRawJson(exchange, respBody);
}
}
private void log(String msg) {
String timestamp = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss"));
String entry = "[" + timestamp + "] " + msg;
@@ -87,16 +87,18 @@ public class ProgressBar {
}
public static void finish(String message) {
try {
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
java.lang.reflect.Method setInProgress = jfxClass.getMethod("setInstallInProgress", boolean.class);
setInProgress.invoke(null, false);
} catch (Exception ignored) {}
System.out.println("\r" + ZAnsi.brightGreen(message + " done ✓"));
System.out.flush();
}
public static void setStage(String stageName, int stageIndex, int stageCount) {
try {
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
java.lang.reflect.Method setStage = jfxClass.getMethod("setInstallStage", String.class, int.class, int.class);
setStage.invoke(null, stageName, stageIndex, stageCount);
} catch (Exception ignored) {}
}
public static void clearLine() {
System.out.print("\r" + " ".repeat(110) + "\r");
System.out.flush();
+118 -7
View File
@@ -78,7 +78,7 @@
</div>
<nav class="sidebar-nav">
<div class="sidebar-nav-left">
<div class="sidebar-nav-primary">
<button class="nav-btn active" data-view="packs" title="Packs">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
<span data-i18n="nav.packs">Packs</span>
@@ -88,11 +88,15 @@
<span data-i18n="nav.news">News</span>
</button>
</div>
<div class="sidebar-nav-right">
<div class="sidebar-nav-secondary" id="nav-secondary">
<button class="nav-btn" data-view="friends" title="Friends">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span data-i18n="nav.friends">Friends</span>
</button>
<button class="nav-btn hidden" data-view="admin" title="Admin" id="admin-nav-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
<span data-i18n="nav.admin">Admin</span>
</button>
</div>
</nav>
@@ -185,6 +189,12 @@
<div id="pack-gallery" class="pack-gallery">
</div>
</div>
<div id="whitelist-mods-section" class="whitelist-mods-section hidden">
<div class="section-header"><span data-i18n="whitelist.title">Additional Mods</span></div>
<div id="whitelist-mods-list" class="whitelist-mods-list">
<div class="whitelist-loading" data-i18n="whitelist.loading">Loading...</div>
</div>
</div>
</div>
</div>
@@ -233,6 +243,111 @@
</div>
</div>
<!-- Admin View -->
<div id="view-admin" class="view">
<div class="view-header">
<h2 class="view-title" data-i18n="admin.title">Admin Panel</h2>
</div>
<div class="admin-tabs">
<button class="admin-tab active" data-atab="clients"><span data-i18n="admin.clients">Clients</span></button>
<button class="admin-tab" data-atab="mods"><span data-i18n="admin.mods">Mods</span></button>
<button class="admin-tab" data-atab="users"><span data-i18n="admin.users">Users</span></button>
<button class="admin-tab" data-atab="passes"><span data-i18n="admin.passes">Passes</span></button>
<button class="admin-tab" data-atab="news"><span data-i18n="admin.news">News</span></button>
</div>
<div class="admin-content">
<!-- Clients -->
<div id="atab-clients" class="admin-tab-content active">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.clientsOnline">Online Clients</span></div>
<div id="admin-clients-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- Whitelist Mods -->
<div id="atab-mods" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.modsUpload">Upload Mod</span></div>
<div class="admin-row">
<input type="file" id="admin-mod-file" accept=".jar" class="admin-file-input">
<button class="btn-primary btn-sm" onclick="app.adminUploadMod()"><span data-i18n="admin.modsUploadBtn">Upload</span></button>
</div>
<div id="admin-mod-status" class="admin-status hidden"></div>
</div>
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.modsList">Whitelist Mods</span></div>
<div id="admin-mods-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- Users -->
<div id="atab-users" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.usersSearch">User Management</span></div>
<div class="admin-row">
<input type="text" id="admin-user-search" class="admin-input" placeholder="Username..." data-i18n-placeholder="admin.usersSearchPlaceholder">
<button class="btn-primary btn-sm" onclick="app.adminSearchUser()"><span data-i18n="admin.search">Search</span></button>
</div>
<div id="admin-user-results" class="admin-list"></div>
</div>
<div id="admin-user-detail" class="admin-card hidden">
<div class="admin-card-header" id="admin-user-detail-name"></div>
<div class="admin-form">
<div class="admin-row">
<span data-i18n="admin.role">Role:</span>
<select id="admin-user-role-select" class="admin-select">
<option value="0">USER</option>
<option value="1">PASS_HOLDER</option>
<option value="2">MODERATOR</option>
<option value="3">ELDER</option>
<option value="4">CREATOR</option>
</select>
<button class="btn-primary btn-sm" onclick="app.adminSetRole()"><span data-i18n="admin.save">Save</span></button>
</div>
<div class="admin-row">
<button class="btn-secondary btn-sm btn-danger" onclick="app.adminResetPassword()"><span data-i18n="admin.resetPass">Reset Password</span></button>
</div>
</div>
</div>
</div>
<!-- Passes -->
<div id="atab-passes" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.passesList">Pass Management</span></div>
<div id="admin-passes-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- News -->
<div id="atab-news" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.newsCreate">Create News</span></div>
<div class="admin-form">
<div class="admin-row">
<input type="text" id="admin-news-title" class="admin-input" placeholder="Title" data-i18n-placeholder="admin.newsTitlePlaceholder">
</div>
<div class="admin-row">
<select id="admin-news-type" class="admin-select">
<option value="Update">Update</option>
<option value="Announcement">Announcement</option>
<option value="Event">Event</option>
</select>
<input type="text" id="admin-news-version" class="admin-input" placeholder="Version" style="max-width:120px">
</div>
<div class="admin-row">
<textarea id="admin-news-body" class="admin-textarea" placeholder="Body (Markdown)" data-i18n-placeholder="admin.newsBodyPlaceholder"></textarea>
</div>
<div class="admin-row">
<button class="btn-primary btn-sm" onclick="app.adminCreateNews()"><span data-i18n="admin.newsCreateBtn">Publish</span></button>
</div>
<div id="admin-news-status" class="admin-status hidden"></div>
</div>
</div>
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.newsManage">Manage News</span></div>
<div id="admin-news-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
</div>
</div>
<!-- Settings View -->
<div id="view-settings" class="view">
<div class="view-header">
@@ -345,8 +460,8 @@
</div>
<div class="modal-body">
<div class="field">
<input type="text" id="add-friend-input" placeholder=" " data-i18n-placeholder="friends.addPlaceholder">
<label data-i18n="friends.addLabel">Username</label>
<input type="text" id="add-friend-input" placeholder="Enter username..." data-i18n-placeholder="friends.addPlaceholder">
</div>
<button id="add-friend-submit" class="btn-primary" onclick="app.submitAddFriend()"><span data-i18n="friends.add">Add Friend</span></button>
<p id="add-friend-error" class="error-msg hidden"></p>
@@ -391,10 +506,6 @@
<option value="">Loading...</option>
</select>
</div>
<div class="field">
<label data-i18n="install.localName.label">Local Name</label>
<input type="text" id="zernmc-instance-name" placeholder="my-cool-pack">
</div>
<button id="install-zernmc-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button>
</div>
+573 -42
View File
@@ -14,6 +14,7 @@ const LOCALES = {
'pack.noDescription': 'No description available',
'stat.loaderVer': 'Loader Ver', 'stat.files': 'Files', 'stat.size': 'Size',
'playBar.play': 'Play', 'playBar.passRequired': 'Pass Required',
'playBar.update': 'Update', 'playBar.updating': 'Updating...',
'settings.title': 'Settings',
'settings.activatePass.title': 'Activate Pass',
'settings.activatePass.desc': 'Enter your pass code to access server packs',
@@ -74,6 +75,8 @@ const LOCALES = {
'toast.loginFailed': 'Login failed',
'toast.launching': 'Launching {name}...',
'toast.launchFailed': 'Launch failed',
'toast.updated': '{name} updated to v{version}!',
'toast.updateFailed': 'Update failed',
'toast.noPackSelected': 'Select a pack first',
'toast.launched': 'Launched! PID: {pid}',
'toast.installing': 'Installing...',
@@ -116,6 +119,53 @@ const LOCALES = {
'playtime.hours': '{h}h {m}m',
'playtime.total': 'Total: {h}h',
'playtime.syncing': 'Syncing...',
'whitelist.title': 'Additional Mods',
'whitelist.loading': 'Loading...',
'whitelist.empty': 'No additional mods available',
'whitelist.error': 'Failed to load mods',
'whitelist.install': 'Install Selected',
'whitelist.installing': 'Installing...',
'whitelist.installed': 'Installed',
'whitelist.done': 'Mods installed!',
'whitelist.selectNone': 'Select mods first',
'whitelist.installError': 'Failed to install mod',
'nav.admin': 'Admin',
'admin.title': 'Admin Panel',
'admin.clients': 'Clients',
'admin.mods': 'Mods',
'admin.users': 'Users',
'admin.passes': 'Passes',
'admin.news': 'News',
'admin.clientsOnline': 'Online Clients',
'admin.noClients': 'No connected clients',
'admin.modsUpload': 'Upload Mod',
'admin.modsUploadBtn': 'Upload',
'admin.modsList': 'Whitelist Mods',
'admin.modsEmpty': 'No whitelist mods',
'admin.modsUploaded': 'Mod uploaded successfully!',
'admin.modsDeleted': 'Mod deleted',
'admin.usersSearch': 'User Management',
'admin.usersSearchPlaceholder': 'Username...',
'admin.search': 'Search',
'admin.role': 'Role:',
'admin.save': 'Save',
'admin.roleUpdated': 'Role updated!',
'admin.resetPass': 'Reset Password',
'admin.passReset': 'Password reset! New password: {pass}',
'admin.noUser': 'No user selected',
'admin.passesList': 'Pass Management',
'admin.passesEmpty': 'No passes',
'admin.newsCreate': 'Create News',
'admin.newsTitlePlaceholder': 'Title',
'admin.newsBodyPlaceholder': 'Body (Markdown)',
'admin.newsCreateBtn': 'Publish',
'admin.newsManage': 'Manage News',
'admin.newsCreated': 'News published!',
'admin.newsDeleted': 'News deleted',
'admin.newsEmpty': 'No news',
'admin.delete': 'Delete',
'admin.loading': 'Loading...',
'admin.error': 'Error',
},
ru: {
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
@@ -130,6 +180,7 @@ const LOCALES = {
'pack.noDescription': 'Описание отсутствует',
'stat.loaderVer': 'Версия загрузчика', 'stat.files': 'Файлы', 'stat.size': 'Размер',
'playBar.play': 'Играть', 'playBar.passRequired': 'Требуется проходка',
'playBar.update': 'Обновить', 'playBar.updating': 'Обновление...',
'settings.title': 'Настройки',
'settings.activatePass.title': 'Активировать проходку',
'settings.activatePass.desc': 'Введите код проходки для доступа к серверным сборкам',
@@ -190,6 +241,8 @@ const LOCALES = {
'toast.loginFailed': 'Ошибка входа',
'toast.launching': 'Запуск {name}...',
'toast.launchFailed': 'Ошибка запуска',
'toast.updated': '{name} обновлён до v{version}!',
'toast.updateFailed': 'Ошибка обновления',
'toast.noPackSelected': 'Сначала выберите сборку',
'toast.launched': 'Запущено! PID: {pid}',
'toast.installing': 'Установка...',
@@ -232,6 +285,53 @@ const LOCALES = {
'playtime.hours': '{h}ч {m}м',
'playtime.total': 'Всего: {h}ч',
'playtime.syncing': 'Синхронизация...',
'whitelist.title': 'Дополнительные моды',
'whitelist.loading': 'Загрузка...',
'whitelist.empty': 'Нет дополнительных модов',
'whitelist.error': 'Ошибка загрузки модов',
'whitelist.install': 'Установить выбранные',
'whitelist.installing': 'Установка...',
'whitelist.installed': 'Установлено',
'whitelist.done': 'Моды установлены!',
'whitelist.selectNone': 'Выберите моды',
'whitelist.installError': 'Ошибка установки мода',
'nav.admin': 'Админка',
'admin.title': 'Панель администратора',
'admin.clients': 'Клиенты',
'admin.mods': 'Моды',
'admin.users': 'Пользователи',
'admin.passes': 'Проходки',
'admin.news': 'Новости',
'admin.clientsOnline': 'Онлайн клиенты',
'admin.noClients': 'Нет подключенных клиентов',
'admin.modsUpload': 'Загрузить мод',
'admin.modsUploadBtn': 'Загрузить',
'admin.modsList': 'Вайтлист моды',
'admin.modsEmpty': 'Нет модов в вайтлисте',
'admin.modsUploaded': 'Мод успешно загружен!',
'admin.modsDeleted': 'Мод удалён',
'admin.usersSearch': 'Управление пользователями',
'admin.usersSearchPlaceholder': 'Имя пользователя...',
'admin.search': 'Поиск',
'admin.role': 'Роль:',
'admin.save': 'Сохранить',
'admin.roleUpdated': 'Роль обновлена!',
'admin.resetPass': 'Сбросить пароль',
'admin.passReset': 'Пароль сброшен! Новый пароль: {pass}',
'admin.noUser': 'Пользователь не выбран',
'admin.passesList': 'Управление проходками',
'admin.passesEmpty': 'Нет проходок',
'admin.newsCreate': 'Создать новость',
'admin.newsTitlePlaceholder': 'Заголовок',
'admin.newsBodyPlaceholder': 'Текст (Markdown)',
'admin.newsCreateBtn': 'Опубликовать',
'admin.newsManage': 'Управление новостями',
'admin.newsCreated': 'Новость опубликована!',
'admin.newsDeleted': 'Новость удалена',
'admin.newsEmpty': 'Нет новостей',
'admin.delete': 'Удалить',
'admin.loading': 'Загрузка...',
'admin.error': 'Ошибка',
}
};
@@ -463,6 +563,7 @@ class ZernMCLauncher {
this.loadPlaytimeStats();
this.startFriendStatusHeartbeat();
this.enhanceSelects();
this.updateAdminNav();
}
async loadServerPacksList() {
@@ -524,6 +625,10 @@ class ZernMCLauncher {
afInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') app.submitAddFriend(); });
}
document.querySelectorAll('.admin-tab').forEach(function(t) {
t.addEventListener('click', function() { app.switchAdminTab(t.dataset.atab); });
});
var localeSelect = document.getElementById('locale-select');
if (localeSelect) {
localeSelect.addEventListener('change', async () => {
@@ -558,10 +663,36 @@ class ZernMCLauncher {
const r = await this.req('/instances');
if (r.success && r.data) {
this.state.instances = r.data;
this.state.updateMap = {};
await this.checkUpdatesForServerPacks();
this.renderSidebar();
}
}
async checkUpdatesForServerPacks() {
if (!this.state.instances) return;
for (const inst of this.state.instances) {
if (!inst.isServerPack) continue;
const r = await this.req('/check-updates?name=' + encodeURIComponent(inst.name));
if (r.success && r.data) {
this.state.updateMap[inst.name] = r.data.hasUpdate;
}
}
}
async checkUpdateForPack(inst) {
if (!inst || !inst.isServerPack) return;
const r = await this.req('/check-updates?name=' + encodeURIComponent(inst.name));
if (r.success && r.data) {
if (!this.state.updateMap) this.state.updateMap = {};
this.state.updateMap[inst.name] = r.data.hasUpdate;
}
}
hasUpdate(inst) {
return inst && inst.isServerPack && this.state.updateMap && this.state.updateMap[inst.name] === true;
}
renderSidebar() {
const serverList = document.getElementById('server-packs-list');
const localList = document.getElementById('local-packs-list');
@@ -578,15 +709,17 @@ class ZernMCLauncher {
const el = document.createElement('div');
el.className = 'pack-entry' + (this.state.selectedPack && this.state.selectedPack.name === inst.name ? ' selected' : '');
const hasUpd = this.hasUpdate(inst);
el.innerHTML = `
<div class="pack-entry-icon ${isZern ? 'server' : 'local'}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${isZern
? '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>'
: '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>'
}</svg>
${hasUpd ? '<span class="update-badge"></span>' : ''}
</div>
<div class="pack-entry-info">
<div class="pack-entry-name">${this.esc(inst.name)}</div>
<div class="pack-entry-name">${this.esc(inst.name)}${hasUpd ? ' <span class="update-label">' + t('pack.update') + '</span>' : ''}</div>
<div class="pack-entry-meta">${inst.version || '?'}${inst.loaderType && inst.loaderType !== 'vanilla' ? ' \u00b7 ' + inst.loaderType : ''}</div>
</div>
`;
@@ -611,6 +744,10 @@ class ZernMCLauncher {
// Enable play button immediately so user can click it right away
this.setPlayBtnText('playBar.play', false);
this.showPackDetail(inst);
// Check for updates asynchronously
this.checkUpdateForPack(inst).then(() => {
if (this.state.selectedPack === inst) this.showPackDetail(inst);
});
// Refresh account in background to check pass status
this.refreshAccount().then(() => {
if (this.state.selectedPack === inst) {
@@ -673,7 +810,7 @@ class ZernMCLauncher {
serverTag.classList.add('hidden');
}
document.getElementById('detail-loader-ver').textContent = inst.loaderVersion || '-';
document.getElementById('detail-files').textContent = inst.filesCount || '0';
document.getElementById('detail-files').textContent = '...';
var playtimeEl = document.getElementById('detail-playtime');
var stats = this.state.playtimeStats;
@@ -701,6 +838,8 @@ class ZernMCLauncher {
var passActive = this.state.account ? this.state.account.passActive : false;
if (inst.isServerPack && !passActive) {
this.setPlayBtnText('playBar.passRequired', true);
} else if (this.hasUpdate(inst)) {
this.setPlayBtnText('playBar.update', false);
} else {
this.setPlayBtnText('playBar.play', false);
}
@@ -723,6 +862,15 @@ class ZernMCLauncher {
// Load pack info from local files
this.loadPackInfo(inst.name);
// Load whitelist mods for server packs
const whitelistSection = document.getElementById('whitelist-mods-section');
if (inst.isServerPack) {
this.loadWhitelistMods(inst.name);
whitelistSection.classList.remove('hidden');
} else {
whitelistSection.classList.add('hidden');
}
}
async loadPackInfo(name) {
@@ -739,6 +887,348 @@ class ZernMCLauncher {
}
}
async loadWhitelistMods(instanceName) {
const listEl = document.getElementById('whitelist-mods-list');
listEl.innerHTML = '<div class="whitelist-loading">' + t('whitelist.loading') + '</div>';
const r = await this.req('/whitelist/mods');
if (!r.success || !r.data) {
listEl.innerHTML = '<div class="whitelist-loading">' + (r.error || t('whitelist.error')) + '</div>';
return;
}
if (r.data.length === 0) {
listEl.innerHTML = '<div class="whitelist-loading">' + t('whitelist.empty') + '</div>';
return;
}
var html = '';
r.data.forEach(function(mod) {
var sizeStr = mod.size > 1048576 ? (mod.size / 1048576).toFixed(1) + ' MB' : Math.ceil(mod.size / 1024) + ' KB';
html += '<label class="whitelist-mod-item">'
+ '<input type="checkbox" class="whitelist-mod-checkbox" data-hash="' + mod.hash + '" data-name="' + mod.name + '">'
+ '<div class="whitelist-mod-info">'
+ '<div class="whitelist-mod-name">' + this.esc(mod.name) + '</div>'
+ '<div class="whitelist-mod-size">' + sizeStr + '</div>'
+ '</div>'
+ '</label>';
}, this);
html += '<button class="whitelist-install-btn" onclick="app.installSelectedWhitelistMods(\'' + instanceName + '\')">'
+ '<span data-i18n="whitelist.install">Install Selected</span></button>';
listEl.innerHTML = html;
}
async installSelectedWhitelistMods(instanceName) {
var checkboxes = document.querySelectorAll('.whitelist-mod-checkbox:checked');
if (checkboxes.length === 0) { this.toast(t('whitelist.selectNone'), 'warning'); return; }
var btn = document.querySelector('.whitelist-install-btn');
btn.disabled = true;
btn.textContent = t('whitelist.installing');
for (var i = 0; i < checkboxes.length; i++) {
var cb = checkboxes[i];
var modName = cb.getAttribute('data-name');
var modHash = cb.getAttribute('data-hash');
var r = await this.req('/whitelist/mods/install', {
method: 'POST',
body: JSON.stringify({ instance: instanceName, modName: modName, modHash: modHash })
});
if (r.success) {
cb.checked = false;
cb.parentElement.querySelector('.whitelist-mod-info').innerHTML += '<div class="whitelist-mod-status">' + t('whitelist.installed') + '</div>';
} else {
this.toast(r.error || t('whitelist.installError'), 'error');
}
}
btn.disabled = false;
btn.innerHTML = '<span data-i18n="whitelist.install">Install Selected</span>';
this.toast(t('whitelist.done'), 'success');
}
// ==================== ADMIN ====================
updateAdminNav() {
var a = this.state.account;
var btn = document.getElementById('admin-nav-btn');
if (!btn) return;
if (a && a.role >= 2) {
btn.classList.remove('hidden');
} else {
btn.classList.add('hidden');
}
}
switchAdminTab(tab) {
document.querySelectorAll('.admin-tab').forEach(function(t) {
t.classList.toggle('active', t.dataset.atab === tab);
});
document.querySelectorAll('.admin-tab-content').forEach(function(c) {
c.classList.toggle('active', c.id === 'atab-' + tab);
});
if (tab === 'clients') this.adminLoadClients();
if (tab === 'mods') this.adminLoadMods();
if (tab === 'users') this.adminClearUser();
if (tab === 'passes') this.adminLoadPasses();
if (tab === 'news') this.adminLoadNewsList();
}
// --- Clients ---
async adminLoadClients() {
var el = document.getElementById('admin-clients-list');
if (!el) return;
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
var r = await this.req('/admin/clients');
if (!r.success) {
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + ': ' + (r.error || '') + '</div>';
return;
}
var clients = r.clients || r.data || [];
if (!clients.length) {
el.innerHTML = '<div class="admin-empty">' + t('admin.noClients') + '</div>';
return;
}
var html = '';
clients.forEach(function(c) {
html += '<div class="admin-item">'
+ '<span class="admin-item-name">' + app.esc(c.username || c.name || '?') + '</span>'
+ '<span class="admin-item-meta">IP: ' + app.esc(c.ip || '-') + '</span>'
+ '</div>';
});
el.innerHTML = html;
setTimeout(function() { app.adminLoadClients(); }, 5000);
}
// --- Mods ---
async adminLoadMods() {
var el = document.getElementById('admin-mods-list');
if (!el) return;
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
var r = await this.req('/admin/whitelist/mods');
if (!r.success) {
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
return;
}
var mods = r.mods || r.data || [];
if (!mods.length) {
el.innerHTML = '<div class="admin-empty">' + t('admin.modsEmpty') + '</div>';
return;
}
var html = '';
mods.forEach(function(m) {
var sizeStr = m.size > 1048576 ? (m.size / 1048576).toFixed(1) + ' MB' : Math.ceil(m.size / 1024) + ' KB';
html += '<div class="admin-item">'
+ '<span class="admin-item-name">' + app.esc(m.name) + '</span>'
+ '<span class="admin-item-meta">' + sizeStr + '</span>'
+ '<div class="admin-item-actions">'
+ '<button class="btn-secondary btn-sm btn-danger" onclick="app.adminDeleteMod(\'' + m.hash + '\')">' + t('admin.delete') + '</button>'
+ '</div></div>';
});
el.innerHTML = html;
}
async adminUploadMod() {
var input = document.getElementById('admin-mod-file');
var status = document.getElementById('admin-mod-status');
if (!input || !input.files || !input.files[0]) { this.toast('Select a file', 'error'); return; }
var file = input.files[0];
if (!file.name.endsWith('.jar')) { this.toast('Only .jar files', 'error'); return; }
var reader = new FileReader();
reader.onload = async function(e) {
var base64 = e.target.result.split(',')[1];
var r = await app.req('/admin/whitelist/mods', {
method: 'POST',
body: JSON.stringify({ filename: file.name, data: base64 })
});
status.classList.remove('hidden');
if (r.success) {
status.textContent = t('admin.modsUploaded');
status.className = 'admin-status success';
input.value = '';
app.adminLoadMods();
} else {
status.textContent = r.error || t('admin.error');
status.className = 'admin-status error';
}
};
reader.readAsDataURL(file);
}
async adminDeleteMod(hash) {
var r = await this.req('/admin/whitelist/mods/' + hash, { method: 'DELETE' });
if (r.success) {
this.toast(t('admin.modsDeleted'), 'success');
this.adminLoadMods();
} else {
this.toast(r.error || t('admin.error'), 'error');
}
}
// --- Users ---
async adminSearchUser() {
var input = document.getElementById('admin-user-search');
var results = document.getElementById('admin-user-results');
var query = input.value.trim();
if (!query) return;
results.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
var r = await this.req('/admin/users/search', {
method: 'POST',
body: JSON.stringify({ search: query })
});
if (!r.success) {
results.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
return;
}
var users = r.users || r.data || [];
if (!users.length) {
results.innerHTML = '<div class="admin-empty">' + t('admin.noUser') + '</div>';
return;
}
var html = '';
users.forEach(function(u) {
html += '<div class="admin-item" style="cursor:pointer" onclick="app.adminSelectUser(' + u.id + ',\'' + app.esc(u.username) + '\',' + (u.role || 0) + ')">'
+ '<span class="admin-item-name">' + app.esc(u.username) + '</span>'
+ '<span class="admin-item-meta">' + (u.role_name || 'Role ' + (u.role || 0)) + '</span>'
+ '</div>';
});
results.innerHTML = html;
}
adminClearUser() {
document.getElementById('admin-user-results').innerHTML = '';
document.getElementById('admin-user-detail').classList.add('hidden');
document.getElementById('admin-user-search').value = '';
}
adminSelectUser(id, username, role) {
var detail = document.getElementById('admin-user-detail');
document.getElementById('admin-user-detail-name').textContent = username;
document.getElementById('admin-user-role-select').value = String(role);
detail.dataset.userId = id;
detail.dataset.username = username;
detail.classList.remove('hidden');
}
async adminSetRole() {
var detail = document.getElementById('admin-user-detail');
var userId = detail.dataset.userId;
var username = detail.dataset.username;
var role = parseInt(document.getElementById('admin-user-role-select').value);
if (!userId) { this.toast(t('admin.noUser'), 'error'); return; }
var r = await this.req('/admin/users/role', {
method: 'POST',
body: JSON.stringify({ user_id: parseInt(userId), role: role, username: username })
});
if (r.success) {
this.toast(t('admin.roleUpdated'), 'success');
} else {
this.toast(r.error || t('admin.error'), 'error');
}
}
async adminResetPassword() {
var detail = document.getElementById('admin-user-detail');
var userId = detail.dataset.userId;
var username = detail.dataset.username;
if (!userId) { this.toast(t('admin.noUser'), 'error'); return; }
var r = await this.req('/admin/users/reset-password', {
method: 'POST',
body: JSON.stringify({ user_id: parseInt(userId), username: username })
});
if (r.success) {
this.toast(tr('admin.passReset', null, {pass: r.new_password || r.password || ''}), 'success');
} else {
this.toast(r.error || t('admin.error'), 'error');
}
}
// --- Passes ---
async adminLoadPasses() {
var el = document.getElementById('admin-passes-list');
if (!el) return;
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
var r = await this.req('/admin/passes');
if (!r.success) {
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
return;
}
var passes = r.passes || r.data || [];
if (!passes.length) {
el.innerHTML = '<div class="admin-empty">' + t('admin.passesEmpty') + '</div>';
return;
}
var html = '';
passes.forEach(function(p) {
html += '<div class="admin-item">'
+ '<span class="admin-item-name">' + app.esc(p.code || p.name || '?') + '</span>'
+ '<span class="admin-item-meta">' + (p.username ? app.esc(p.username) : '') + '</span>'
+ '</div>';
});
el.innerHTML = html;
}
// --- News ---
async adminLoadNewsList() {
var el = document.getElementById('admin-news-list');
if (!el) return;
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
var r = await this.req('/news');
if (!r.success) {
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
return;
}
var newsList = [];
try { newsList = (typeof r.data === 'string' ? JSON.parse(r.data) : r.data).news || []; } catch(e) {}
if (!newsList.length) {
el.innerHTML = '<div class="admin-empty">' + t('admin.newsEmpty') + '</div>';
return;
}
var html = '';
newsList.forEach(function(item, idx) {
html += '<div class="admin-news-item">'
+ '<div class="admin-news-info">'
+ '<div class="admin-news-title">' + app.esc(item.title) + '</div>'
+ '<div class="admin-news-meta">' + app.esc(item.type) + ' \u00b7 ' + app.esc(item.version || '') + '</div>'
+ '</div>'
+ '<div class="admin-item-actions">'
+ '<button class="btn-secondary btn-sm btn-danger" onclick="app.adminDeleteNews(\'' + idx + '\')">' + t('admin.delete') + '</button>'
+ '</div></div>';
});
el.innerHTML = html;
}
async adminCreateNews() {
var title = document.getElementById('admin-news-title').value.trim();
var type = document.getElementById('admin-news-type').value;
var version = document.getElementById('admin-news-version').value.trim();
var body = document.getElementById('admin-news-body').value.trim();
var status = document.getElementById('admin-news-status');
if (!title || !body) { this.toast('Fill in title and body', 'error'); return; }
var r = await this.req('/admin/news', {
method: 'POST',
body: JSON.stringify({ title: title, type: type, version: version, body: body })
});
status.classList.remove('hidden');
if (r.success) {
status.textContent = t('admin.newsCreated');
status.className = 'admin-status success';
document.getElementById('admin-news-title').value = '';
document.getElementById('admin-news-version').value = '';
document.getElementById('admin-news-body').value = '';
this.adminLoadNewsList();
} else {
status.textContent = r.error || t('admin.error');
status.className = 'admin-status error';
}
}
async adminDeleteNews(idx) {
var r = await this.req('/admin/news/delete', {
method: 'POST',
body: JSON.stringify({ index: parseInt(idx) })
});
if (r.success) {
this.toast(t('admin.newsDeleted'), 'success');
this.adminLoadNewsList();
} else {
this.toast(r.error || t('admin.error'), 'error');
}
}
// ==================== NEWS ====================
async loadNews() {
const r = await this.req('/news');
@@ -844,6 +1334,10 @@ class ZernMCLauncher {
this.toast(t('toast.noPackSelected'), 'warning');
return;
}
if (this.hasUpdate(inst)) {
await this.updateSelectedPack();
return;
}
this.toast(tr('toast.launching', null, {name: inst.name}), 'info');
const r = await this.req('/launch', { method: 'POST', body: JSON.stringify({ name: inst.name }) });
if (r.success) {
@@ -854,52 +1348,87 @@ class ZernMCLauncher {
}
}
async updateSelectedPack() {
const inst = this.state.selectedPack;
if (!inst) return;
this.setPlayBtnText('playBar.updating', true);
const r = await this.req('/update', { method: 'POST', body: JSON.stringify({ name: inst.name }) });
if (r.success) {
this.toast(tr('toast.updated', null, {name: inst.name, version: String(r.data?.version || '')}), 'success');
if (this.state.updateMap) {
this.state.updateMap[inst.name] = false;
}
await this.loadInstances();
} else {
this.toast(r.error || t('toast.updateFailed'), 'error');
this.setPlayBtnText('playBar.update', false);
}
}
// ==================== INSTALL ====================
async showInstallModal() {
document.getElementById('install-modal').classList.remove('hidden');
document.getElementById('zernmc-pack-select').innerHTML = '<option>' + t('select.loading') + '</option>';
document.getElementById('mc-version-select').innerHTML = '<option>' + t('select.loading') + '</option>';
const packs = await this.req('/packs');
const zernmcSel = document.getElementById('zernmc-pack-select');
if (packs.success && packs.data && packs.data.length > 0) {
if (this.state.account && this.state.account.passActive) {
zernmcSel.innerHTML = '<option value="">' + t('select.selectPack') + '</option>';
zernmcSel.disabled = false;
packs.data.forEach(p => {
const o = document.createElement('option');
o.value = p.name;
o.textContent = (p.displayName || p.name) + ' (' + (p.version || '') + ')';
zernmcSel.appendChild(o);
});
} else {
zernmcSel.innerHTML = '<option value="">' + t('select.passRequired') + '</option>';
const mcSel = document.getElementById('mc-version-select');
if (zernmcSel) {
zernmcSel.innerHTML = '<option>' + t('select.loading') + '</option>';
}
if (mcSel) {
mcSel.innerHTML = '<option>' + t('select.loading') + '</option>';
}
try {
const packs = await this.req('/packs');
if (zernmcSel && packs.success && Array.isArray(packs.data) && packs.data.length > 0) {
if (this.state.account && this.state.account.passActive) {
zernmcSel.innerHTML = '<option value="">' + t('select.selectPack') + '</option>';
zernmcSel.disabled = false;
packs.data.forEach(p => {
const o = document.createElement('option');
o.value = p.name;
o.textContent = (p.displayName || p.name) + ' (' + (p.version || '') + ')';
zernmcSel.appendChild(o);
});
} else {
zernmcSel.innerHTML = '<option value="">' + t('select.passRequired') + '</option>';
zernmcSel.disabled = true;
}
} else if (zernmcSel) {
zernmcSel.innerHTML = '<option value="">' + t('select.noPacks') + '</option>';
zernmcSel.disabled = true;
}
if (this.state.account && !this.state.account.passActive) {
const zernmcTab = document.querySelector('[data-tab="zernmc"]');
if (zernmcTab) zernmcTab.style.opacity = '0.5';
} else {
const zernmcTab = document.querySelector('[data-tab="zernmc"]');
if (zernmcTab) zernmcTab.style.opacity = '1';
}
} catch (e) {
if (zernmcSel) {
zernmcSel.innerHTML = '<option value="">' + t('select.failedLoad') + '</option>';
zernmcSel.disabled = true;
}
} else {
zernmcSel.innerHTML = '<option value="">' + t('select.noPacks') + '</option>';
zernmcSel.disabled = true;
}
if (mcSel) this.enhanceSelects();
if (this.state.account && !this.state.account.passActive) {
document.querySelector('[data-tab="zernmc"]').style.opacity = '0.5';
} else {
document.querySelector('[data-tab="zernmc"]').style.opacity = '1';
}
this.enhanceSelects();
const mc = await this.req('/mc-versions');
const mcSel = document.getElementById('mc-version-select');
if (mc.success && mc.data) {
mcSel.innerHTML = '<option value="">' + t('select.selectVersion') + '</option>';
mc.data.forEach(v => {
const o = document.createElement('option');
o.value = v; o.textContent = v;
mcSel.appendChild(o);
});
} else {
mcSel.innerHTML = '<option value="">' + t('select.failedLoad') + '</option>';
if (mcSel) {
try {
const mc = await this.req('/mc-versions');
if (mc.success && Array.isArray(mc.data)) {
mcSel.innerHTML = '<option value="">' + t('select.selectVersion') + '</option>';
mc.data.forEach(v => {
const o = document.createElement('option');
o.value = v; o.textContent = v;
mcSel.appendChild(o);
});
} else {
mcSel.innerHTML = '<option value="">' + t('select.failedLoad') + '</option>';
}
} catch (e) {
mcSel.innerHTML = '<option value="">' + t('select.failedLoad') + '</option>';
}
}
this.enhanceSelects();
}
@@ -1054,9 +1583,9 @@ class ZernMCLauncher {
async installZernMCPack() {
const packName = document.getElementById('zernmc-pack-select').value;
const instanceName = document.getElementById('zernmc-instance-name').value.trim();
if (!packName) { this.toast(t('select.selectPack'), 'error'); return; }
if (!instanceName) { this.toast(t('install.enterName'), 'error'); return; }
// Use server pack name as instance name (no local naming)
const instanceName = packName;
const r = await this.req('/install', {
method: 'POST',
@@ -1288,6 +1817,8 @@ class ZernMCLauncher {
closeAddFriend() {
document.getElementById('add-friend-modal').classList.add('hidden');
document.getElementById('add-friend-input').value = '';
document.getElementById('add-friend-error').classList.add('hidden');
}
async submitAddFriend() {
+128 -8
View File
@@ -92,7 +92,9 @@ body {
background: var(--bg-elevated); padding: 0 4px;
}
.field input:focus + label,
.field input:not(:placeholder-shown) + label {
.field input:not(:placeholder-shown) + label,
.field textarea:focus + label,
.field textarea:not(:placeholder-shown) + label {
top: 0; font-size: 11px; color: var(--accent);
}
.field input {
@@ -174,7 +176,7 @@ body {
padding: 16px 12px;
}
.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 20px; overflow: hidden; }
.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
.sidebar-brand {
display: flex; align-items: center; gap: 10px;
@@ -185,12 +187,15 @@ body {
.sidebar-brand-ver { font-size: 11px; color: var(--text-muted); }
.sidebar-nav {
display: flex; justify-content: space-between; gap: 4px;
display: flex; flex-direction: column; gap: 4px;
padding-bottom: 16px; border-bottom: 1px solid var(--border);
}
.sidebar-nav-left, .sidebar-nav-right {
.sidebar-nav-primary, .sidebar-nav-secondary {
display: flex; gap: 4px;
}
.sidebar-nav-primary .nav-btn, .sidebar-nav-secondary .nav-btn {
flex: 1;
}
.nav-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px; background: transparent; border: 1px solid transparent;
@@ -226,6 +231,16 @@ body {
.pack-entry:hover { background: var(--bg-card); }
.pack-entry.selected { background: var(--accent-soft); border-color: rgba(233,69,96,0.25); }
.pack-entry-icon { position: relative; }
.pack-entry-icon .update-badge {
position: absolute; top: -3px; right: -3px; width: 10px; height: 10px;
border-radius: 50%; background: #e94560; border: 2px solid var(--bg-main, #0f0f1a);
}
.update-label {
display: inline-block; font-size: 10px; font-weight: 600; color: #e94560;
background: rgba(233,69,96,0.12); padding: 0 5px; border-radius: 3px;
margin-left: 4px; vertical-align: middle; line-height: 16px;
}
.pack-entry-icon {
width: 32px; height: 32px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
@@ -552,7 +567,7 @@ body {
.modal {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); width: 90%; max-width: 480px;
max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);
max-height: 85vh; box-shadow: var(--shadow);
animation: floatIn 0.3s ease;
}
.modal-head {
@@ -668,12 +683,11 @@ body {
}
@media (max-width: 700px) {
.sidebar { width: 56px; min-width: 56px; }
.sidebar-brand-text, .sidebar-nav .nav-btn span,
.sidebar-brand-text, .sidebar-nav-primary .nav-btn span, .sidebar-nav-secondary .nav-btn span,
.section-header, .pack-entry-info, .user-info,
.sidebar-bottom .user-card .btn-icon:first-child { display: none; }
.sidebar-brand { justify-content: center; padding: 8px; }
.sidebar-nav { flex-direction: column; }
.sidebar-nav-left, .sidebar-nav-right { flex-direction: column; }
.sidebar-nav-primary, .sidebar-nav-secondary { flex-direction: column; }
.nav-btn { padding: 8px; }
.pack-entry { justify-content: center; padding: 8px; }
.content { padding: 12px; }
@@ -756,8 +770,114 @@ body {
.friend-request-text { font-size: 11px; color: var(--text-muted); }
.friend-request-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* ========== WHITELIST MODS ========== */
.whitelist-mods-section {
margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border);
}
.whitelist-mods-section .section-header {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: var(--text-muted); padding: 4px; margin-bottom: 8px; letter-spacing: 0.5px;
}
.whitelist-mods-list { display: flex; flex-direction: column; gap: 4px; }
.whitelist-loading {
text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px;
}
.whitelist-mod-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: var(--radius-sm); transition: var(--transition);
background: var(--bg-card);
}
.whitelist-mod-item:hover { background: var(--bg-card-hover); }
.whitelist-mod-checkbox {
width: 18px; height: 18px; cursor: pointer; accent-color: var(--accent);
}
.whitelist-mod-info { flex: 1; min-width: 0; }
.whitelist-mod-name { font-size: 13px; font-weight: 500; color: var(--text); }
.whitelist-mod-size { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.whitelist-mod-status { font-size: 11px; color: var(--success); }
.whitelist-install-btn {
width: 100%; padding: 10px; margin-top: 8px;
background: var(--accent-soft); border: 1px solid rgba(233,69,96,0.3);
border-radius: var(--radius-sm); color: var(--accent); font-size: 13px;
font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition);
}
.whitelist-install-btn:hover { background: var(--accent); color: #fff; }
.whitelist-install-btn:disabled { opacity: 0.4; cursor: default; }
/* ========== MODAL SM ========== */
.modal-sm { max-width: 360px; }
/* ========== BADGE SM ========== */
.badge-sm { font-size: 10px; padding: 2px 6px; }
/* ========== ADMIN PANEL ========== */
.admin-tabs {
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
}
.admin-tab {
padding: 8px 14px; background: transparent; border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text-muted); font-size: 12px;
font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition);
}
.admin-tab.active { background: var(--accent-soft); border-color: rgba(233,69,96,0.3); color: var(--accent); }
.admin-tab:hover:not(.active) { background: var(--bg-card); color: var(--text-secondary); }
.admin-tab-content { display: none; flex-direction: column; gap: 12px; }
.admin-tab-content.active { display: flex; }
.admin-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 16px;
}
.admin-card-header {
font-size: 13px; font-weight: 600; margin-bottom: 12px; padding-bottom: 8px;
border-bottom: 1px solid var(--border); color: var(--text);
}
.admin-list { display: flex; flex-direction: column; gap: 4px; }
.admin-loading, .admin-empty {
text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px;
}
.admin-row {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap;
}
.admin-input {
flex: 1; padding: 8px 12px; background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text); font-size: 13px; outline: none; font-family: var(--font);
}
.admin-input:focus { border-color: var(--accent); }
.admin-select {
padding: 6px 10px; background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text); font-size: 12px; outline: none; cursor: pointer; font-family: var(--font);
}
.admin-textarea {
width: 100%; min-height: 100px; padding: 10px; background: var(--bg-surface);
border: 1px solid var(--border-light); border-radius: var(--radius-sm); color: var(--text);
font-size: 13px; outline: none; font-family: var(--font); resize: vertical;
}
.admin-textarea:focus { border-color: var(--accent); }
.admin-file-input {
font-size: 12px; color: var(--text-secondary); font-family: var(--font);
}
.admin-status {
font-size: 12px; padding: 8px 12px; border-radius: var(--radius-sm); margin-top: 8px;
}
.admin-status.success { background: rgba(74,222,128,0.1); color: var(--success); }
.admin-status.error { background: rgba(248,113,113,0.1); color: var(--error); }
.admin-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: var(--radius-sm); transition: var(--transition); font-size: 13px;
}
.admin-item:hover { background: var(--bg-card-hover); }
.admin-item-name { flex: 1; color: var(--text); }
.admin-item-meta { font-size: 11px; color: var(--text-muted); }
.admin-item-actions { display: flex; gap: 6px; flex-shrink: 0; }
.admin-form { display: flex; flex-direction: column; gap: 8px; }
.admin-btn-danger { color: var(--error) !important; }
.admin-btn-danger:hover { background: rgba(248,113,113,0.1) !important; border-color: rgba(248,113,113,0.3) !important; }
.admin-news-item {
display: flex; align-items: flex-start; gap: 10px; padding: 10px;
border-radius: var(--radius-sm); transition: var(--transition); border: 1px solid var(--border);
margin-bottom: 6px;
}
.admin-news-item:hover { background: var(--bg-card-hover); }
.admin-news-info { flex: 1; min-width: 0; }
.admin-news-title { font-size: 13px; font-weight: 500; }
.admin-news-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; }