DevBlog №4 | массовый фикс JFX и фиксы связанные с самим лаунчером, добавление админ меню
This commit is contained in:
@@ -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) {
|
||||
|
||||
+31
@@ -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) {
|
||||
|
||||
+90
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user