aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/ui/src
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src')
-rw-r--r--ui/src/App.svelte919
-rw-r--r--ui/src/app.css2
-rw-r--r--ui/src/components/BottomBar.svelte79
-rw-r--r--ui/src/components/HomeView.svelte57
-rw-r--r--ui/src/components/LoginModal.svelte30
-rw-r--r--ui/src/components/ModLoaderSelector.svelte245
-rw-r--r--ui/src/components/ParticleBackground.svelte57
-rw-r--r--ui/src/components/SettingsView.svelte286
-rw-r--r--ui/src/components/Sidebar.svelte76
-rw-r--r--ui/src/components/StatusToast.svelte42
-rw-r--r--ui/src/components/VersionsView.svelte255
-rw-r--r--ui/src/lib/DownloadMonitor.svelte109
-rw-r--r--ui/src/lib/GameConsole.svelte2
-rw-r--r--ui/src/lib/effects/ConstellationEffect.ts163
-rw-r--r--ui/src/lib/effects/SaturnEffect.ts194
-rw-r--r--ui/src/lib/modLoaderApi.ts108
-rw-r--r--ui/src/stores/settings.svelte.ts10
-rw-r--r--ui/src/types/index.ts65
18 files changed, 1694 insertions, 1005 deletions
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index b637512..1c465b1 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -1,98 +1,9 @@
<script lang="ts">
import { getVersion } from "@tauri-apps/api/app";
- import { onMount } from "svelte";
+ import { onMount, onDestroy } from "svelte";
+ import { convertFileSrc } from "@tauri-apps/api/core";
import DownloadMonitor from "./lib/DownloadMonitor.svelte";
import GameConsole from "./lib/GameConsole.svelte";
-
- let status = "Ready";
- let showConsole = false;
- let currentView = "home";
- let statusTimeout: any;
- let appVersion = "...";
-
- // Watch for status changes to auto-dismiss
- $: if (status !== "Ready") {
- if (statusTimeout) clearTimeout(statusTimeout);
- statusTimeout = setTimeout(() => {
- status = "Ready";
- }, 5000);
- }
-
- interface Version {
- id: string;
- type: string;
- url: string;
- time: string;
- releaseTime: string;
- }
-
- interface Account {
- type: "Offline" | "Microsoft";
- username: string;
- uuid: string;
- }
-
- interface DeviceCodeResponse {
- user_code: string;
- device_code: string;
- verification_uri: string;
- expires_in: number;
- interval: number;
- message?: string;
- }
-
- interface LauncherConfig {
- min_memory: number;
- max_memory: number;
- java_path: string;
- width: number;
- height: number;
- }
-
- interface JavaInstallation {
- path: string;
- version: string;
- is_64bit: boolean;
- }
-
- interface JavaDownloadInfo {
- version: string;
- release_name: string;
- download_url: string;
- file_name: string;
- file_size: number;
- checksum: string | null;
- image_type: string;
- }
-
- let versions: Version[] = [];
- let selectedVersion = "";
- let currentAccount: Account | null = null;
- let settings: LauncherConfig = {
- min_memory: 1024,
- max_memory: 2048,
- java_path: "java",
- width: 854,
- height: 480,
- };
- let javaInstallations: JavaInstallation[] = [];
- let isDetectingJava = false;
-
- let availableJavaVersions: number[] = [];
- let selectedJavaVersion = 21;
- let selectedImageType: "jre" | "jdk" = "jre";
- let isDownloadingJava = false;
- let javaDownloadStatus = "";
- let showJavaDownloadModal = false;
-
- // Login UI State
- let isLoginModalOpen = false;
- let loginMode: "select" | "offline" | "microsoft" = "select";
- let offlineUsername = "";
- let deviceCodeData: DeviceCodeResponse | null = null;
- let msLoginLoading = false;
- let msLoginStatus = "Waiting for authorization...";
- let isPollingRequestActive = false;
// Components
import Sidebar from "./components/Sidebar.svelte";
@@ -102,6 +13,7 @@
import BottomBar from "./components/BottomBar.svelte";
import LoginModal from "./components/LoginModal.svelte";
import StatusToast from "./components/StatusToast.svelte";
+ import ParticleBackground from "./components/ParticleBackground.svelte";
// Stores
import { uiState } from "./stores/ui.svelte";
@@ -109,726 +21,157 @@
import { settingsState } from "./stores/settings.svelte";
import { gameState } from "./stores/game.svelte";
+ let mouseX = $state(0);
+ let mouseY = $state(0);
+
+ function handleMouseMove(e: MouseEvent) {
+ mouseX = (e.clientX / window.innerWidth) * 2 - 1;
+ mouseY = (e.clientY / window.innerHeight) * 2 - 1;
+ }
+
onMount(async () => {
authState.checkAccount();
- settingsState.loadSettings();
+ await settingsState.loadSettings();
gameState.loadVersions();
getVersion().then((v) => (uiState.appVersion = v));
+ window.addEventListener("mousemove", handleMouseMove);
});
-
- async function checkAccount() {
- try {
- const acc = await invoke("get_active_account");
- currentAccount = acc as Account | null;
- } catch (e) {
- console.error("Failed to check account:", e);
- }
- }
-
- async function loadSettings() {
- try {
- settings = await invoke("get_settings");
- } catch (e) {
- console.error("Failed to load settings:", e);
- }
- }
-
- async function saveSettings() {
- try {
- await invoke("save_settings", { config: settings });
- status = "Settings saved!";
- } catch (e) {
- console.error("Failed to save settings:", e);
- status = "Error saving settings: " + e;
- }
- }
-
- async function detectJava() {
- isDetectingJava = true;
- try {
- javaInstallations = await invoke("detect_java");
- if (javaInstallations.length === 0) {
- status = "No Java installations found";
- } else {
- status = `Found ${javaInstallations.length} Java installation(s)`;
- }
- } catch (e) {
- console.error("Failed to detect Java:", e);
- status = "Error detecting Java: " + e;
- } finally {
- isDetectingJava = false;
- }
- }
-
- function selectJava(path: string) {
- settings.java_path = path;
- }
-
- async function openJavaDownloadModal() {
- showJavaDownloadModal = true;
- javaDownloadStatus = "";
- try {
- availableJavaVersions = await invoke("fetch_available_java_versions");
- // Default selection logic
- if (availableJavaVersions.includes(21)) {
- selectedJavaVersion = 21;
- } else if (availableJavaVersions.includes(17)) {
- selectedJavaVersion = 17;
- } else if (availableJavaVersions.length > 0) {
- selectedJavaVersion = availableJavaVersions[availableJavaVersions.length - 1];
- }
- } catch (e) {
- console.error("Failed to fetch available Java versions:", e);
- javaDownloadStatus = "Error fetching Java versions: " + e;
- }
- }
-
- function closeJavaDownloadModal() {
- if (!isDownloadingJava) {
- showJavaDownloadModal = false;
- }
- }
-
- async function downloadJava() {
- isDownloadingJava = true;
- javaDownloadStatus = `Downloading Java ${selectedJavaVersion} ${selectedImageType.toUpperCase()}...`;
+
+ $effect(() => {
+ // ENFORCE DARK MODE: Always add 'dark' class and attribute
+ // This combined with the @variant dark in app.css ensures dark mode is always active
+ // regardless of system preference settings.
+ document.documentElement.classList.add('dark');
+ document.documentElement.setAttribute('data-theme', 'dark');
- try {
- const result: JavaInstallation = await invoke("download_adoptium_java", {
- majorVersion: selectedJavaVersion,
- imageType: selectedImageType,
- customPath: null,
- });
-
- javaDownloadStatus = `Java ${selectedJavaVersion} installed at ${result.path}`;
- settings.java_path = result.path;
-
- await detectJava();
-
- setTimeout(() => {
- showJavaDownloadModal = false;
- status = `Java ${selectedJavaVersion} is ready to use!`;
- }, 1500);
- } catch (e) {
- console.error("Failed to download Java:", e);
- javaDownloadStatus = "Download failed: " + e;
- } finally {
- isDownloadingJava = false;
- }
- }
-
- // --- Auth Functions ---
-
- function openLoginModal() {
- if (currentAccount) {
- if (confirm("Logout " + currentAccount.username + "?")) {
- invoke("logout").then(() => (currentAccount = null));
- }
- return;
- }
- // Reset state
- isLoginModalOpen = true;
- loginMode = "select";
- offlineUsername = "";
- deviceCodeData = null;
- msLoginLoading = false;
- }
-
- function closeLoginModal() {
- stopPolling();
- isLoginModalOpen = false;
- }
-
- async function performOfflineLogin() {
- if (!offlineUsername) return;
- try {
- currentAccount = (await invoke("login_offline", {
- username: offlineUsername,
- })) as Account;
- isLoginModalOpen = false;
- } catch (e) {
- alert("Login failed: " + e);
- }
- }
-
- let pollInterval: any;
-
- // Cleanup on destroy/close
- function stopPolling() {
- if (pollInterval) {
- clearInterval(pollInterval);
- pollInterval = null;
- }
- }
-
- async function startMicrosoftLogin() {
- loginMode = "microsoft";
- msLoginLoading = true;
- msLoginStatus = "Waiting for authorization...";
- stopPolling(); // Ensure no duplicates
-
- try {
- deviceCodeData = (await invoke(
- "start_microsoft_login"
- )) as DeviceCodeResponse;
-
- // UX Improvements: Auto Copy & Auto Open
- if (deviceCodeData) {
- try {
- await navigator.clipboard.writeText(deviceCodeData.user_code);
- } catch (e) {
- console.error("Clipboard failed", e);
- }
-
- openLink(deviceCodeData.verification_uri);
-
- // Start Polling
- console.log("Starting polling for token...");
- const intervalMs = (deviceCodeData.interval || 5) * 1000;
- pollInterval = setInterval(
- () => checkLoginStatus(deviceCodeData!.device_code),
- intervalMs
- );
- }
- } catch (e) {
- alert("Failed to start Microsoft login: " + e);
- loginMode = "select"; // Go back
- } finally {
- msLoginLoading = false;
- }
- }
-
- async function checkLoginStatus(deviceCode: string) {
- if (isPollingRequestActive) return;
- isPollingRequestActive = true;
-
- console.log("Polling Microsoft API...");
- try {
- // This will fail with "authorization_pending" until user logs in
- currentAccount = (await invoke("complete_microsoft_login", {
- deviceCode,
- })) as Account;
-
- // If success:
- console.log("Login Successful!", currentAccount);
- stopPolling();
- isLoginModalOpen = false;
- status = "Welcome back, " + currentAccount.username;
- } catch (e: any) {
- const errStr = e.toString();
- if (errStr.includes("authorization_pending")) {
- console.log("Status: Waiting for user to authorize...");
- // Keep checking
- } else {
- // Real error
- console.error("Polling Error:", errStr);
- msLoginStatus = "Error: " + errStr;
-
- // Optional: Stop polling on fatal errors?
- // expired_token should stop it.
- if (
- errStr.includes("expired_token") ||
- errStr.includes("access_denied")
- ) {
- stopPolling();
- alert("Login failed: " + errStr);
- loginMode = "select";
- }
- }
- } finally {
- isPollingRequestActive = false;
- }
- }
-
- // Clean up manual button to just be a status indicator or 'Retry Now'
- async function completeMicrosoftLogin() {
- if (deviceCodeData) checkLoginStatus(deviceCodeData.device_code);
- }
-
- function openLink(url: string) {
- open(url);
- }
-
- async function startGame() {
- if (!currentAccount) {
- alert("Please login first!");
- openLoginModal();
- return;
- }
-
- if (!selectedVersion) {
- alert("Please select a version!");
- return;
- }
+ // Ensure 'light' class is never present
+ document.documentElement.classList.remove('light');
+ });
- status = "Preparing to launch " + selectedVersion + "...";
- console.log("Invoking start_game for version:", selectedVersion);
- try {
- const msg = await invoke("start_game", { versionId: selectedVersion });
- console.log("Response:", msg);
- status = msg as string;
- } catch (e) {
- console.error(e);
- status = "Error: " + e;
- }
- }
+ onDestroy(() => {
+ if (typeof window !== 'undefined')
+ window.removeEventListener("mousemove", handleMouseMove);
+ });
</script>
<div
- class="flex h-screen bg-zinc-900 text-white font-sans overflow-hidden select-none"
+ class="relative h-screen w-screen overflow-hidden dark:text-white text-gray-900 font-sans selection:bg-indigo-500/30"
>
- <Sidebar />
-
- <!-- Main Content -->
- <main class="flex-1 flex flex-col relative min-w-0">
- <DownloadMonitor />
- <!-- Top Bar (Window Controls Placeholder) -->
- <div
- class="h-8 w-full bg-zinc-900/50 absolute top-0 left-0 z-50 drag-region"
- data-tauri-drag-region
- >
- <!-- Windows/macOS controls would go here or be handled by OS -->
- </div>
-
- <!-- Background / Poster area -->
- <div class="flex-1 relative overflow-hidden group">
- {#if currentView === "home"}
- <!-- Background Image - Using gradient fallback -->
- <div
- class="absolute inset-0 z-0 opacity-60 bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950 transition-transform duration-[10s] ease-linear group-hover:scale-105"
- ></div>
- <div
- class="absolute inset-0 z-0 bg-gradient-to-t from-zinc-900 via-transparent to-black/50"
- ></div>
-
- <div class="absolute bottom-24 left-8 z-10 p-4">
- <h1
- class="text-6xl font-black mb-2 tracking-tight text-white drop-shadow-lg"
- >
- MINECRAFT
- </h1>
- <div class="flex items-center gap-2 text-zinc-300">
- <span
- class="bg-zinc-800 text-xs px-2 py-1 rounded border border-zinc-600"
- >JAVA EDITION</span
- >
- <span class="text-lg">Release 1.20.4</span>
- </div>
- </div>
- {:else if currentView === "versions"}
- <div class="p-8 h-full overflow-y-auto bg-zinc-900">
- <h2 class="text-3xl font-bold mb-6">Versions</h2>
- <div class="grid gap-2">
- {#if versions.length === 0}
- <div class="text-zinc-500">Loading versions...</div>
- {:else}
- {#each versions as version}
- <button
- class="flex items-center justify-between p-4 bg-zinc-800 rounded hover:bg-zinc-700 transition text-left border border-zinc-700 {selectedVersion ===
- version.id
- ? 'border-green-500 bg-zinc-800/80 ring-1 ring-green-500'
- : ''}"
- onclick={() => (selectedVersion = version.id)}
- >
- <div>
- <div class="font-bold font-mono text-lg">{version.id}</div>
- <div class="text-xs text-zinc-400 capitalize">
- {version.type} • {new Date(
- version.releaseTime
- ).toLocaleDateString()}
- </div>
- </div>
- {#if selectedVersion === version.id}
- <div class="text-green-500 font-bold text-sm">SELECTED</div>
- {/if}
- </button>
- {/each}
- {/if}
- </div>
- </div>
- {:else if currentView === "settings"}
- <div class="p-8 bg-zinc-900 h-full overflow-y-auto">
- <h2 class="text-3xl font-bold mb-8">Settings</h2>
-
- <div class="space-y-6 max-w-2xl">
- <!-- Java Path -->
- <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700">
- <label
- class="block text-sm font-bold text-zinc-400 mb-2 uppercase tracking-wide"
- >Java Executable Path</label
- >
- <div class="flex gap-2">
- <input
- bind:value={settings.java_path}
- type="text"
- class="bg-zinc-950 text-white flex-1 p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none font-mono text-sm"
- placeholder="e.g. java, /usr/bin/java"
- />
- <button
- onclick={detectJava}
- disabled={isDetectingJava}
- class="bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-white px-4 py-2 rounded transition-colors whitespace-nowrap"
- >
- {isDetectingJava ? "Detecting..." : "Auto Detect"}
- </button>
- <button
- onclick={openJavaDownloadModal}
- class="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded transition-colors whitespace-nowrap"
- >
- Download Java
- </button>
- </div>
-
- {#if javaInstallations.length > 0}
- <div class="mt-4 space-y-2">
- <p class="text-xs text-zinc-400 uppercase font-bold">Detected Java Installations:</p>
- {#each javaInstallations as java}
- <button
- onclick={() => selectJava(java.path)}
- class="w-full text-left p-3 bg-zinc-950 rounded border transition-colors {settings.java_path === java.path ? 'border-indigo-500 bg-indigo-950/30' : 'border-zinc-700 hover:border-zinc-500'}"
- >
- <div class="flex justify-between items-center">
- <div>
- <span class="text-white font-mono text-sm">{java.version}</span>
- <span class="text-zinc-500 text-xs ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span>
- </div>
- {#if settings.java_path === java.path}
- <span class="text-indigo-400 text-xs">Selected</span>
- {/if}
- </div>
- <div class="text-zinc-500 text-xs font-mono truncate mt-1">{java.path}</div>
- </button>
- {/each}
- </div>
- {/if}
-
- <p class="text-xs text-zinc-500 mt-2">
- The command or path to the Java Runtime Environment. Click "Auto Detect" to find installed Java versions.
- </p>
- </div>
-
- <!-- Memory -->
- <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700">
- <label
- class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide"
- >Memory Allocation (RAM)</label
- >
-
- <div class="grid grid-cols-2 gap-6">
- <div>
- <label class="block text-xs text-zinc-500 mb-1"
- >Minimum (MB)</label
- >
- <input
- bind:value={settings.min_memory}
- type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
- />
- </div>
- <div>
- <label class="block text-xs text-zinc-500 mb-1"
- >Maximum (MB)</label
- >
- <input
- bind:value={settings.max_memory}
- type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
- />
- </div>
- </div>
- </div>
-
- <!-- Resolution -->
- <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700">
- <label
- class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide"
- >Game Window Size</label
- >
- <div class="grid grid-cols-2 gap-6">
- <div>
- <label class="block text-xs text-zinc-500 mb-1">Width</label>
- <input
- bind:value={settings.width}
- type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
- />
- </div>
- <div>
- <label class="block text-xs text-zinc-500 mb-1">Height</label>
- <input
- bind:value={settings.height}
- type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
- />
- </div>
- </div>
- </div>
+ <!-- Modern Animated Background -->
+ <div class="absolute inset-0 z-0 bg-[#09090b] dark:bg-[#09090b] bg-gray-100 overflow-hidden">
+ {#if settingsState.settings.custom_background_path}
+ <img
+ src={convertFileSrc(settingsState.settings.custom_background_path)}
+ alt="Background"
+ class="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear hover:scale-105"
+ />
+ <!-- Dimming Overlay for readability -->
+ <div class="absolute inset-0 bg-black/50 "></div>
+ {:else if settingsState.settings.enable_visual_effects}
+ <!-- Original Gradient (Dark Only / or Adjusted for Light) -->
+ {#if settingsState.settings.theme === 'dark'}
+ <div
+ class="absolute inset-0 opacity-60 bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950"
+ ></div>
+ {:else}
+ <!-- Light Mode Gradient -->
+ <div
+ class="absolute inset-0 opacity-100 bg-gradient-to-br from-emerald-100 via-gray-100 to-indigo-100"
+ ></div>
+ {/if}
- <div class="pt-4">
- <button
- onclick={saveSettings}
- class="bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-8 rounded shadow-lg transition-transform active:scale-95"
- >
- Save Settings
- </button>
- </div>
- </div>
- </div>
{#if uiState.currentView === "home"}
- <HomeView />
- {:else if uiState.currentView === "versions"}
- <VersionsView />
- {:else if uiState.currentView === "settings"}
- <SettingsView />
+ <ParticleBackground />
{/if}
- </div>
-
- <BottomBar />
- </main>
-
- <!-- Login Modal -->
- {#if isLoginModalOpen}
- <div
- class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
- >
- <div
- class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200"
- >
- <div class="flex justify-between items-center mb-6">
- <h2 class="text-2xl font-bold text-white">Login</h2>
- <button
- onclick={closeLoginModal}
- class="text-zinc-500 hover:text-white transition group"
- >
- ✕
- </button>
- </div>
-
- {#if loginMode === "select"}
- <div class="space-y-4">
- <button
- onclick={startMicrosoftLogin}
- class="w-full flex items-center justify-center gap-3 bg-[#2F2F2F] hover:bg-[#3F3F3F] text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-500 transition-all group"
- >
- <!-- Microsoft Logo SVG -->
- <svg
- class="w-5 h-5"
- viewBox="0 0 23 23"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- ><path fill="#f35325" d="M1 1h10v10H1z" /><path
- fill="#81bc06"
- d="M12 1h10v10H12z"
- /><path fill="#05a6f0" d="M1 12h10v10H1z" /><path
- fill="#ffba08"
- d="M12 12h10v10H12z"
- /></svg
- >
- Microsoft Account
- </button>
-
- <div class="relative py-2">
- <div class="absolute inset-0 flex items-center">
- <div class="w-full border-t border-zinc-700"></div>
- </div>
- <div class="relative flex justify-center text-xs uppercase">
- <span class="bg-zinc-900 px-2 text-zinc-500">OR</span>
- </div>
- </div>
- <div class="space-y-2">
- <input
- type="text"
- bind:value={offlineUsername}
- placeholder="Offline Username"
- class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-white focus:border-indigo-500 outline-none"
- onkeydown={(e) => e.key === "Enter" && performOfflineLogin()}
- />
- <button
- onclick={performOfflineLogin}
- class="w-full bg-zinc-800 hover:bg-zinc-700 text-zinc-300 p-3 rounded font-medium transition-colors"
- >
- Offline Login
- </button>
- </div>
- </div>
- {:else if loginMode === "microsoft"}
- <div class="text-center">
- {#if msLoginLoading && !deviceCodeData}
- <div class="py-8 text-zinc-400 animate-pulse">
- Starting login flow...
- </div>
- {:else if deviceCodeData}
- <div class="space-y-4">
- <p class="text-sm text-zinc-400">1. Go to this URL:</p>
- <button
- onclick={() =>
- deviceCodeData && openLink(deviceCodeData.verification_uri)}
- class="text-indigo-400 hover:text-indigo-300 underline break-all font-mono text-sm"
- >
- {deviceCodeData.verification_uri}
- </button>
-
- <p class="text-sm text-zinc-400 mt-2">2. Enter this code:</p>
- <div
- class="bg-zinc-950 p-4 rounded border border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors"
- onclick={() =>
- navigator.clipboard.writeText(
- deviceCodeData?.user_code || ""
- )}
- >
- {deviceCodeData.user_code}
- </div>
- <p class="text-xs text-zinc-500">Click code to copy</p>
-
- <div class="pt-6 space-y-3">
- <div class="flex flex-col items-center gap-3">
- <div class="animate-spin rounded-full h-6 w-6 border-2 border-zinc-600 border-t-indigo-500"></div>
- <span class="text-sm text-zinc-400 font-medium break-all text-center">{msLoginStatus}</span>
- </div>
- <p class="text-xs text-zinc-600">This window will update automatically.</p>
- </div>
-
- <button
- onclick={() => { stopPolling(); loginMode = "select"; }}
- class="text-xs text-zinc-500 hover:text-zinc-300 mt-6 underline"
- >Cancel</button
- >
- </div>
- {/if}
- </div>
- {/if}
- </div>
+ <div
+ class="absolute inset-0 bg-gradient-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50 from-gray-100 to-transparent"
+ ></div>
+ {/if}
+
+ <!-- Subtle Grid Overlay -->
+ <div class="absolute inset-0 z-0 opacity-10 dark:opacity-10 opacity-30 pointer-events-none"
+ style="background-image: linear-gradient({settingsState.settings.theme === 'dark' ? '#ffffff' : '#000000'} 1px, transparent 1px), linear-gradient(90deg, {settingsState.settings.theme === 'dark' ? '#ffffff' : '#000000'} 1px, transparent 1px); background-size: 40px 40px; mask-image: radial-gradient(circle at 50% 50%, black 30%, transparent 70%);">
</div>
- {/if}
+ </div>
- <!-- Overlay Status (Toast) -->
- {#if status !== "Ready"}
- <div
- class="absolute top-12 right-12 bg-zinc-800/90 backdrop-blur border border-zinc-600 p-4 rounded-lg shadow-2xl max-w-sm animate-in fade-in slide-in-from-top-4 duration-300 z-50 group"
- >
- <div class="flex justify-between items-start mb-1">
- <div class="text-xs text-zinc-400 uppercase font-bold">Status</div>
- <button
- onclick={() => (status = "Ready")}
- class="text-zinc-500 hover:text-white transition -mt-1 -mr-1 p-1"
- >
- ✕
- </button>
- </div>
- <div class="font-mono text-sm whitespace-pre-wrap mb-2">{status}</div>
- <div class="w-full bg-zinc-700/50 h-1 rounded-full overflow-hidden">
- <div
- class="h-full bg-indigo-500 animate-[progress_5s_linear_forwards] origin-left w-full"
- ></div>
- </div>
- </div>
- {/if}
+ <!-- Content Wrapper -->
+ <div class="relative z-10 flex h-full p-4 gap-4 text-gray-900 dark:text-white">
+ <!-- Floating Sidebar -->
+ <Sidebar />
- {#if showJavaDownloadModal}
- <div
- class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
- onclick={closeJavaDownloadModal}
- >
+ <!-- Main Content Area - Transparent & Flat -->
+ <main class="flex-1 flex flex-col relative min-w-0 overflow-hidden transition-all duration-300">
+
+ <!-- Window Drag Region -->
<div
- class="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md shadow-2xl"
- onclick={(e) => e.stopPropagation()}
- >
- <div class="flex justify-between items-center mb-6">
- <h3 class="text-xl font-bold">Download Java (Adoptium)</h3>
- {#if !isDownloadingJava}
- <button
- onclick={closeJavaDownloadModal}
- class="text-zinc-500 hover:text-white transition text-xl"
- >
- ✕
- </button>
- {/if}
- </div>
- <div class="space-y-4">
- <!-- Version Selection -->
- <div>
- <label class="block text-sm font-bold text-zinc-400 mb-2">Java Version</label>
- <select
- bind:value={selectedJavaVersion}
- disabled={isDownloadingJava}
- class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-white focus:border-indigo-500 outline-none disabled:opacity-50"
- >
- {#each availableJavaVersions as ver}
- <option value={ver}>
- Java {ver} {ver === 21 ? "(Recommended)" : ver === 17 ? "(LTS)" : ver === 8 ? "(Legacy)" : ""}
- </option>
- {/each}
- </select>
- <p class="text-xs text-zinc-500 mt-1">
- MC 1.20.5+ requires Java 21, MC 1.17-1.20.4 requires Java 17, older versions require Java 8
- </p>
+ class="h-8 w-full absolute top-0 left-0 z-50 drag-region"
+ data-tauri-drag-region
+ ></div>
+
+ <!-- App Content -->
+ <div class="flex-1 relative overflow-hidden flex flex-col">
+ <!-- Views Container -->
+ <div class="flex-1 relative overflow-hidden">
+ {#if uiState.currentView === "home"}
+ <HomeView mouseX={mouseX} mouseY={mouseY} />
+ {:else if uiState.currentView === "versions"}
+ <VersionsView />
+ {:else if uiState.currentView === "settings"}
+ <SettingsView />
+ {/if}
</div>
-
- <!-- Image Type Selection -->
- <div>
- <label class="block text-sm font-bold text-zinc-400 mb-2">Type</label>
- <div class="flex gap-3">
- <button
- onclick={() => selectedImageType = "jre"}
- disabled={isDownloadingJava}
- class="flex-1 p-3 rounded border transition-colors disabled:opacity-50 {selectedImageType === 'jre' ? 'border-indigo-500 bg-indigo-950/30 text-white' : 'border-zinc-700 bg-zinc-950 text-zinc-400 hover:border-zinc-500'}"
- >
- <div class="font-bold">JRE</div>
- <div class="text-xs opacity-70">runtime environment</div>
- </button>
- <button
- onclick={() => selectedImageType = "jdk"}
- disabled={isDownloadingJava}
- class="flex-1 p-3 rounded border transition-colors disabled:opacity-50 {selectedImageType === 'jdk' ? 'border-indigo-500 bg-indigo-950/30 text-white' : 'border-zinc-700 bg-zinc-950 text-zinc-400 hover:border-zinc-500'}"
- >
- <div class="font-bold">JDK</div>
- <div class="text-xs opacity-70">development kit</div>
- </button>
- </div>
+
+ <!-- Download Monitor Overlay -->
+ <div class="absolute bottom-20 left-4 right-4 pointer-events-none z-20">
+ <div class="pointer-events-auto">
+ <DownloadMonitor />
+ </div>
</div>
-
- <!-- Status -->
- {#if javaDownloadStatus}
- <div class="p-3 rounded {javaDownloadStatus.startsWith('✓') ? 'bg-green-950/50 border border-green-700 text-green-400' : javaDownloadStatus.includes('failed') || javaDownloadStatus.includes('Failed') ? 'bg-red-950/50 border border-red-700 text-red-400' : 'bg-zinc-800 border border-zinc-700 text-zinc-300'}">
- <p class="text-sm">{javaDownloadStatus}</p>
- </div>
- {/if}
-
- <!-- Download Button -->
- <button
- onclick={downloadJava}
- disabled={isDownloadingJava || availableJavaVersions.length === 0}
- class="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white p-3 rounded font-bold transition-colors flex items-center justify-center gap-2"
- >
- {#if isDownloadingJava}
- <div class="animate-spin rounded-full h-5 w-5 border-2 border-white/30 border-t-white"></div>
- Downloading...
- {:else}
- Download Java {selectedJavaVersion} {selectedImageType.toUpperCase()}
- {/if}
- </button>
-
- <p class="text-xs text-zinc-500 text-center">
- Provided by <a href="https://adoptium.net" class="text-indigo-400 hover:underline" onclick={(e) => { e.preventDefault(); openLink("https://adoptium.net"); }}>Eclipse Adoptium</a>
- </p>
- </div>
+
+ <!-- Bottom Bar -->
+ <BottomBar />
</div>
- </div>
- {/if}
+ </main>
+ </div>
- <style>
- @keyframes progress {
- from {
- transform: scaleX(1);
- }
- to {
- transform: scaleX(0);
- }
- }
- </style>
<LoginModal />
<StatusToast />
-
- <GameConsole visible={uiState.showConsole} />
+
+ {#if uiState.showConsole}
+ <!-- Assuming GameConsole handles its own display mode or overlay -->
+ <div class="fixed inset-0 z-[100] bg-black/80 flex items-center justify-center p-10">
+ <div class="w-full h-full bg-[#1e1e1e] rounded-xl overflow-hidden border border-white/10 shadow-2xl relative">
+ <button class="absolute top-4 right-4 text-white hover:text-red-400 z-10" onclick={() => uiState.toggleConsole()}>✕</button>
+ <GameConsole />
+ </div>
+ </div>
+ {/if}
</div>
+
+<style>
+ :global(body) {
+ margin: 0;
+ padding: 0;
+ background: #000;
+ }
+
+ /* Modern Scrollbar */
+ :global(*::-webkit-scrollbar) {
+ width: 6px;
+ height: 6px;
+ }
+
+ :global(*::-webkit-scrollbar-track) {
+ background: transparent;
+ }
+
+ :global(*::-webkit-scrollbar-thumb) {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 999px;
+ }
+
+ :global(*::-webkit-scrollbar-thumb:hover) {
+ background: rgba(255, 255, 255, 0.25);
+ }
+</style>
diff --git a/ui/src/app.css b/ui/src/app.css
index f1d8c73..2ea9a8c 100644
--- a/ui/src/app.css
+++ b/ui/src/app.css
@@ -1 +1,3 @@
@import "tailwindcss";
+
+@variant dark (&:where(.dark, .dark *));
diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte
index dcad9e8..0178111 100644
--- a/ui/src/components/BottomBar.svelte
+++ b/ui/src/components/BottomBar.svelte
@@ -5,18 +5,19 @@
</script>
<div
- class="h-24 bg-zinc-900 border-t border-zinc-800 flex items-center px-8 justify-between z-20 shadow-2xl"
+ class="h-24 bg-gradient-to-t from-black/50 to-transparent dark:from-black/50 dark:to-transparent from-white/90 to-transparent border-t dark:border-white/5 border-black/5 flex items-center px-8 justify-between z-20 backdrop-blur-md"
>
- <div class="flex items-center gap-4">
+ <!-- Account Area -->
+ <div class="flex items-center gap-6">
<div
- class="flex items-center gap-4 cursor-pointer hover:opacity-80 transition-opacity"
+ class="group flex items-center gap-4 cursor-pointer transition-all duration-300 hover:scale-105"
onclick={() => authState.openLoginModal()}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && authState.openLoginModal()}
>
<div
- class="w-12 h-12 rounded bg-gradient-to-tr from-indigo-500 to-purple-500 shadow-lg flex items-center justify-center text-white font-bold text-xl overflow-hidden"
+ class="w-12 h-12 rounded-xl bg-gradient-to-tr from-indigo-500 to-purple-500 shadow-lg shadow-indigo-500/20 flex items-center justify-center text-white font-bold text-xl overflow-hidden ring-2 ring-transparent dark:group-hover:ring-white/20 group-hover:ring-black/10 transition-all"
>
{#if authState.currentAccount}
<img
@@ -25,63 +26,73 @@
class="w-full h-full"
/>
{:else}
- ?
+ <span class="text-white/50 text-2xl">?</span>
{/if}
</div>
<div>
- <div class="font-bold text-white text-lg">
- {authState.currentAccount ? authState.currentAccount.username : "Click to Login"}
+ <div class="font-bold dark:text-white text-gray-900 text-lg group-hover:text-indigo-500 dark:group-hover:text-indigo-300 transition-colors">
+ {authState.currentAccount ? authState.currentAccount.username : "Login"}
</div>
- <div class="text-xs text-zinc-400 flex items-center gap-1">
+ <div class="text-xs dark:text-zinc-400 text-gray-500 flex items-center gap-1.5">
<span
- class="w-1.5 h-1.5 rounded-full {authState.currentAccount
- ? 'bg-green-500'
- : 'bg-zinc-500'}"
+ class="w-2 h-2 rounded-full {authState.currentAccount
+ ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]'
+ : 'dark:bg-zinc-600 bg-gray-400'}"
></span>
- {authState.currentAccount ? "Ready" : "Guest"}
+ {authState.currentAccount ? "Ready to play" : "Guest Mode"}
</div>
</div>
</div>
+
+ <div class="h-8 w-px dark:bg-white/10 bg-black/10"></div>
+
<!-- Console Toggle -->
<button
- class="ml-4 text-xs text-zinc-500 hover:text-zinc-300 transition"
+ class="text-xs font-mono dark:text-zinc-500 text-gray-500 dark:hover:text-white hover:text-black transition-colors flex items-center gap-2"
onclick={() => uiState.toggleConsole()}
>
+ <span class="text-lg">_</span>
{uiState.showConsole ? "Hide Logs" : "Show Logs"}
</button>
</div>
- <div class="flex items-center gap-4">
+ <!-- Action Area -->
+ <div class="flex items-center gap-6">
<div class="flex flex-col items-end mr-2">
<label
for="version-select"
- class="text-xs text-zinc-500 mb-1 uppercase font-bold tracking-wider"
- >Version</label
+ class="text-[10px] dark:text-white/40 text-black/40 mb-1 uppercase font-bold tracking-wider"
+ >Selected Version</label
>
- <select
- id="version-select"
- bind:value={gameState.selectedVersion}
- class="bg-zinc-950 text-zinc-200 border border-zinc-700 rounded px-4 py-2 hover:border-zinc-500 transition-colors cursor-pointer outline-none focus:ring-1 focus:ring-indigo-500 w-48"
- >
- {#if gameState.versions.length === 0}
- <option>Loading...</option>
- {:else}
- {#each gameState.versions as version}
- <option value={version.id}>{version.id} ({version.type})</option
- >
- {/each}
- {/if}
- </select>
+ <div class="relative group">
+ <select
+ id="version-select"
+ bind:value={gameState.selectedVersion}
+ class="appearance-none dark:bg-black/40 bg-white/60 dark:text-white text-gray-900 border dark:border-white/10 border-black/10 rounded-xl pl-4 pr-10 py-2.5 dark:hover:border-white/30 hover:border-black/30 transition-all cursor-pointer outline-none focus:ring-2 focus:ring-indigo-500/50 w-56 text-sm font-mono backdrop-blur-sm shadow-inner"
+ >
+ {#if gameState.versions.length === 0}
+ <option>Loading...</option>
+ {:else}
+ {#each gameState.versions as version}
+ <option value={version.id}>{version.id} {version.type !== 'release' ? `(${version.type})` : ''}</option>
+ {/each}
+ {/if}
+ </select>
+ <div class="absolute right-3 top-1/2 -translate-y-1/2 dark:text-white/20 text-black/20 pointer-events-none dark:group-hover:text-white/50 group-hover:text-black/50 transition-colors">▼</div>
+ </div>
</div>
<button
onclick={() => gameState.startGame()}
- class="bg-green-600 hover:bg-green-500 text-white font-bold h-14 px-12 rounded transition-all transform active:scale-95 shadow-[0_0_15px_rgba(22,163,74,0.4)] hover:shadow-[0_0_25px_rgba(22,163,74,0.6)] flex flex-col items-center justify-center uppercase tracking-wider text-lg"
+ class="bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-500 hover:to-green-500 text-white font-bold h-14 px-10 rounded-xl transition-all duration-300 transform hover:scale-105 active:scale-95 shadow-[0_0_20px_rgba(16,185,129,0.3)] hover:shadow-[0_0_40px_rgba(16,185,129,0.5)] flex flex-col items-center justify-center uppercase tracking-widest text-xl relative overflow-hidden group"
>
- Play
+ <div class="absolute inset-0 bg-white/20 translate-y-full group-hover:translate-y-0 transition-transform duration-300 skew-y-12"></div>
+ <span class="relative z-10 flex items-center gap-2">
+ PLAY
+ </span>
<span
- class="text-[10px] font-normal opacity-80 normal-case tracking-normal"
- >Click to launch</span
+ class="relative z-10 text-[9px] font-normal opacity-70 normal-case tracking-wide -mt-1"
+ >Launch Game</span
>
</button>
</div>
diff --git a/ui/src/components/HomeView.svelte b/ui/src/components/HomeView.svelte
index e876c14..9cd8014 100644
--- a/ui/src/components/HomeView.svelte
+++ b/ui/src/components/HomeView.svelte
@@ -1,26 +1,47 @@
<script lang="ts">
- // No script needed currently, just static markup mostly
+ type Props = {
+ mouseX: number;
+ mouseY: number;
+ };
+ let { mouseX = 0, mouseY = 0 }: Props = $props();
</script>
-<!-- Background Image - Using gradient fallback -->
-<div
- class="absolute inset-0 z-0 opacity-60 bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950 transition-transform duration-[10s] ease-linear group-hover:scale-105"
-></div>
-<div
- class="absolute inset-0 z-0 bg-gradient-to-t from-zinc-900 via-transparent to-black/50"
-></div>
+<div class="absolute inset-0 z-0 overflow-hidden">
+ <!-- Parallax Background Layers -->
+
+ <div class="absolute inset-0 bg-gradient-to-t from-[#09090b] via-[#09090b]/40 to-transparent"></div>
+</div>
-<div class="absolute bottom-24 left-8 z-10 p-4">
- <h1
- class="text-6xl font-black mb-2 tracking-tight text-white drop-shadow-lg"
+<div class="relative z-10 h-full flex flex-col justify-end p-12 pb-24">
+ <!-- 3D Floating Hero Text -->
+ <div
+ class="transition-transform duration-200 ease-out origin-bottom-left"
+ style:transform={`perspective(1000px) rotateX(${mouseY * -2}deg) rotateY(${mouseX * 2}deg)`}
>
- MINECRAFT
- </h1>
- <div class="flex items-center gap-2 text-zinc-300">
- <span
- class="bg-zinc-800 text-xs px-2 py-1 rounded border border-zinc-600"
- >JAVA EDITION</span
+ <h1
+ class="text-8xl font-black tracking-tighter dark:text-white text-gray-900 drop-shadow-2xl mb-4"
+ style:text-shadow="0 10px 30px rgba(0,0,0,0.5)"
>
- <span class="text-lg">Release 1.20.4</span>
+ MINECRAFT
+ </h1>
+
+ <div class="flex items-center gap-4">
+ <div
+ class="bg-white/10 dark:bg-white/10 bg-black/5 backdrop-blur-md border dark:border-white/10 border-black/10 px-4 py-1.5 rounded-full text-sm font-bold uppercase tracking-widest text-emerald-500 dark:text-emerald-400 shadow-xl"
+ >
+ Java Edition
+ </div>
+ <div class="text-2xl font-light dark:text-zinc-300 text-gray-600">
+ Latest Release 1.21
+ </div>
+ </div>
+ </div>
+
+ <!-- Action Area -->
+ <div class="mt-8 flex gap-4">
+ <!-- Quick Play Button (Visual only here, logic is in BottomBar usually) -->
+ <div class="dark:text-zinc-400 text-gray-500 text-sm italic">
+ Ready to play. Select version below or hit Launch.
+ </div>
</div>
</div>
diff --git a/ui/src/components/LoginModal.svelte b/ui/src/components/LoginModal.svelte
index f1ac0d5..1886cd9 100644
--- a/ui/src/components/LoginModal.svelte
+++ b/ui/src/components/LoginModal.svelte
@@ -9,16 +9,16 @@
{#if authState.isLoginModalOpen}
<div
- class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
+ class="fixed inset-0 z-[60] flex items-center justify-center bg-white/80 dark:bg-black/80 backdrop-blur-sm p-4"
>
<div
- class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200"
+ class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200"
>
<div class="flex justify-between items-center mb-6">
- <h2 class="text-2xl font-bold text-white">Login</h2>
+ <h2 class="text-2xl font-bold text-gray-900 dark:text-white">Login</h2>
<button
onclick={() => authState.closeLoginModal()}
- class="text-zinc-500 hover:text-white transition group"
+ class="text-zinc-500 hover:text-black dark:hover:text-white transition group"
>
</button>
@@ -28,7 +28,7 @@
<div class="space-y-4">
<button
onclick={() => authState.startMicrosoftLogin()}
- class="w-full flex items-center justify-center gap-3 bg-[#2F2F2F] hover:bg-[#3F3F3F] text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-500 transition-all group"
+ class="w-full flex items-center justify-center gap-3 bg-gray-100 hover:bg-gray-200 dark:bg-[#2F2F2F] dark:hover:bg-[#3F3F3F] text-gray-900 dark:text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-400 dark:hover:border-zinc-500 transition-all group"
>
<!-- Microsoft Logo SVG -->
<svg
@@ -49,10 +49,10 @@
<div class="relative py-2">
<div class="absolute inset-0 flex items-center">
- <div class="w-full border-t border-zinc-700"></div>
+ <div class="w-full border-t border-zinc-200 dark:border-zinc-700"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
- <span class="bg-zinc-900 px-2 text-zinc-500">OR</span>
+ <span class="bg-white dark:bg-zinc-900 px-2 text-zinc-400 dark:text-zinc-500">OR</span>
</div>
</div>
@@ -61,12 +61,12 @@
type="text"
bind:value={authState.offlineUsername}
placeholder="Offline Username"
- class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-white focus:border-indigo-500 outline-none"
+ class="w-full bg-gray-50 border-zinc-200 dark:bg-zinc-950 dark:border-zinc-700 rounded p-3 text-gray-900 dark:text-white focus:border-indigo-500 outline-none"
onkeydown={(e) => e.key === "Enter" && authState.performOfflineLogin()}
/>
<button
onclick={() => authState.performOfflineLogin()}
- class="w-full bg-zinc-800 hover:bg-zinc-700 text-zinc-300 p-3 rounded font-medium transition-colors"
+ class="w-full bg-gray-200 hover:bg-gray-300 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 p-3 rounded font-medium transition-colors"
>
Offline Login
</button>
@@ -80,18 +80,18 @@
</div>
{:else if authState.deviceCodeData}
<div class="space-y-4">
- <p class="text-sm text-zinc-400">1. Go to this URL:</p>
+ <p class="text-sm text-gray-500 dark:text-zinc-400">1. Go to this URL:</p>
<button
onclick={() =>
authState.deviceCodeData && openLink(authState.deviceCodeData.verification_uri)}
- class="text-indigo-400 hover:text-indigo-300 underline break-all font-mono text-sm"
+ class="text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 underline break-all font-mono text-sm"
>
{authState.deviceCodeData.verification_uri}
</button>
- <p class="text-sm text-zinc-400 mt-2">2. Enter this code:</p>
+ <p class="text-sm text-gray-500 dark:text-zinc-400 mt-2">2. Enter this code:</p>
<div
- class="bg-zinc-950 p-4 rounded border border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors"
+ class="bg-gray-50 dark:bg-zinc-950 p-4 rounded border border-zinc-200 dark:border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors dark:text-white text-gray-900"
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && navigator.clipboard.writeText(authState.deviceCodeData?.user_code || "")}
@@ -106,8 +106,8 @@
<div class="pt-6 space-y-3">
<div class="flex flex-col items-center gap-3">
- <div class="animate-spin rounded-full h-6 w-6 border-2 border-zinc-600 border-t-indigo-500"></div>
- <span class="text-sm text-zinc-400 font-medium break-all text-center">{authState.msLoginStatus}</span>
+ <div class="animate-spin rounded-full h-6 w-6 border-2 border-zinc-300 dark:border-zinc-600 border-t-indigo-500"></div>
+ <span class="text-sm text-gray-600 dark:text-zinc-400 font-medium break-all text-center">{authState.msLoginStatus}</span>
</div>
<p class="text-xs text-zinc-600">This window will update automatically.</p>
</div>
diff --git a/ui/src/components/ModLoaderSelector.svelte b/ui/src/components/ModLoaderSelector.svelte
new file mode 100644
index 0000000..fd26382
--- /dev/null
+++ b/ui/src/components/ModLoaderSelector.svelte
@@ -0,0 +1,245 @@
+<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
+ import type {
+ FabricGameVersion,
+ FabricLoaderVersion,
+ ForgeVersion,
+ ModLoaderType,
+ } from "../types";
+
+ interface Props {
+ selectedGameVersion: string;
+ onInstall: (versionId: string) => void;
+ }
+
+ let { selectedGameVersion, onInstall }: Props = $props();
+
+ // State
+ let selectedLoader = $state<ModLoaderType>("vanilla");
+ let isLoading = $state(false);
+ let error = $state<string | null>(null);
+
+ // Fabric state
+ let fabricLoaders = $state<FabricLoaderVersion[]>([]);
+ let selectedFabricLoader = $state("");
+
+ // Forge state
+ let forgeVersions = $state<ForgeVersion[]>([]);
+ let selectedForgeVersion = $state("");
+
+ // Load mod loader versions when game version changes
+ $effect(() => {
+ if (selectedGameVersion && selectedLoader !== "vanilla") {
+ loadModLoaderVersions();
+ }
+ });
+
+ async function loadModLoaderVersions() {
+ isLoading = true;
+ error = null;
+
+ try {
+ if (selectedLoader === "fabric") {
+ const loaders = await invoke<any[]>("get_fabric_loaders_for_version", {
+ gameVersion: selectedGameVersion,
+ });
+ fabricLoaders = loaders.map((l) => l.loader);
+ if (fabricLoaders.length > 0) {
+ // Select first stable version or first available
+ const stable = fabricLoaders.find((l) => l.stable);
+ selectedFabricLoader = stable?.version || fabricLoaders[0].version;
+ }
+ } else if (selectedLoader === "forge") {
+ forgeVersions = await invoke<ForgeVersion[]>(
+ "get_forge_versions_for_game",
+ {
+ gameVersion: selectedGameVersion,
+ }
+ );
+ if (forgeVersions.length > 0) {
+ // Select recommended version first, then latest
+ const recommended = forgeVersions.find((v) => v.recommended);
+ const latest = forgeVersions.find((v) => v.latest);
+ selectedForgeVersion =
+ recommended?.version || latest?.version || forgeVersions[0].version;
+ }
+ }
+ } catch (e) {
+ error = `Failed to load ${selectedLoader} versions: ${e}`;
+ console.error(e);
+ } finally {
+ isLoading = false;
+ }
+ }
+
+ async function installModLoader() {
+ if (!selectedGameVersion) {
+ error = "Please select a Minecraft version first";
+ return;
+ }
+
+ isLoading = true;
+ error = null;
+
+ try {
+ if (selectedLoader === "fabric" && selectedFabricLoader) {
+ const result = await invoke<any>("install_fabric", {
+ gameVersion: selectedGameVersion,
+ loaderVersion: selectedFabricLoader,
+ });
+ onInstall(result.id);
+ } else if (selectedLoader === "forge" && selectedForgeVersion) {
+ const result = await invoke<any>("install_forge", {
+ gameVersion: selectedGameVersion,
+ forgeVersion: selectedForgeVersion,
+ });
+ onInstall(result.id);
+ }
+ } catch (e) {
+ error = `Failed to install ${selectedLoader}: ${e}`;
+ console.error(e);
+ } finally {
+ isLoading = false;
+ }
+ }
+
+ function onLoaderChange(loader: ModLoaderType) {
+ selectedLoader = loader;
+ error = null;
+ if (loader !== "vanilla" && selectedGameVersion) {
+ loadModLoaderVersions();
+ }
+ }
+</script>
+
+<div class="space-y-4">
+ <div class="flex items-center justify-between">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-gray-500 dark:text-white/40">Select Mod Loader</h3>
+ </div>
+
+ <!-- Loader Type Tabs - Segmented Control -->
+ <div class="flex p-1 bg-white/60 dark:bg-black/40 rounded-xl border border-black/5 dark:border-white/5 backdrop-blur-sm">
+ <button
+ class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
+ {selectedLoader === 'vanilla'
+ ? 'bg-white shadow-lg border border-black/5 text-black dark:bg-white/10 dark:text-white dark:border-white/10'
+ : 'text-gray-500 dark:text-zinc-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
+ onclick={() => onLoaderChange("vanilla")}
+ >
+ Vanilla
+ </button>
+ <button
+ class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
+ {selectedLoader === 'fabric'
+ ? 'bg-indigo-100 text-indigo-700 border border-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:shadow-lg dark:border-indigo-500/20'
+ : 'text-gray-500 dark:text-zinc-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
+ onclick={() => onLoaderChange("fabric")}
+ >
+ Fabric
+ </button>
+ <button
+ class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
+ {selectedLoader === 'forge'
+ ? 'bg-orange-100 text-orange-700 border border-orange-200 dark:bg-orange-500/20 dark:text-orange-300 dark:shadow-lg dark:border-orange-500/20'
+ : 'text-gray-500 dark:text-zinc-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
+ onclick={() => onLoaderChange("forge")}
+ >
+ Forge
+ </button>
+ </div>
+
+ <!-- Content Area -->
+ <div class="min-h-[100px] flex flex-col justify-center">
+ {#if selectedLoader === "vanilla"}
+ <div class="text-center p-4 rounded-xl bg-white/40 dark:bg-white/5 border border-dashed border-black/5 dark:border-white/10 text-gray-500 dark:text-white/40 text-sm">
+ No mod loader selected. <br> Pure vanilla experience.
+ </div>
+
+ {:else if !selectedGameVersion}
+ <div class="text-center p-4 rounded-xl bg-red-50 border border-red-200 text-red-700 dark:bg-red-500/10 dark:border-red-500/20 dark:text-red-300 text-sm">
+ ⚠️ Please select a base Minecraft version first.
+ </div>
+
+ {:else if isLoading}
+ <div class="flex flex-col items-center gap-2 text-sm text-gray-500 dark:text-white/50 py-4">
+ <div class="w-6 h-6 border-2 border-gray-200 border-t-gray-500 dark:border-white/20 dark:border-t-white rounded-full animate-spin"></div>
+ Loading {selectedLoader} versions...
+ </div>
+
+ {:else if error}
+ <div class="p-4 bg-red-50 border border-red-200 text-red-700 dark:bg-red-500/10 dark:border-red-500/20 dark:text-red-300 rounded-xl text-sm break-words">
+ {error}
+ </div>
+
+ {:else if selectedLoader === "fabric"}
+ <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
+ <div>
+ <label for="fabric-loader-select" class="block text-xs text-gray-500 dark:text-white/40 mb-2 pl-1"
+ >Loader Version</label
+ >
+ <div class="relative">
+ <select
+ id="fabric-loader-select"
+ class="w-full appearance-none bg-white/80 dark:bg-black/40 border border-black/10 dark:border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-indigo-500/50 text-gray-900 dark:text-white transition-colors"
+ bind:value={selectedFabricLoader}
+ >
+ {#each fabricLoaders as loader}
+ <option value={loader.version}>
+ {loader.version} {loader.stable ? "(stable)" : ""}
+ </option>
+ {/each}
+ </select>
+ <div class="absolute right-4 top-1/2 -translate-y-1/2 text-black/30 dark:text-white/20 pointer-events-none">▼</div>
+ </div>
+ </div>
+
+ <button
+ class="w-full bg-indigo-600 hover:bg-indigo-500 text-white py-3 px-4 rounded-xl font-bold text-sm transition-all shadow-lg shadow-indigo-500/20 disabled:opacity-50 disabled:shadow-none hover:scale-[1.02] active:scale-[0.98]"
+ onclick={installModLoader}
+ disabled={isLoading || !selectedFabricLoader}
+ >
+ Install Fabric {selectedFabricLoader}
+ </button>
+ </div>
+
+ {:else if selectedLoader === "forge"}
+ <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
+ {#if forgeVersions.length === 0}
+ <div class="text-center p-4 text-sm text-gray-500 dark:text-white/40 italic">
+ No Forge versions available for {selectedGameVersion}
+ </div>
+ {:else}
+ <div>
+ <label for="forge-version-select" class="block text-xs text-gray-500 dark:text-white/40 mb-2 pl-1"
+ >Forge Version</label
+ >
+ <div class="relative">
+ <select
+ id="forge-version-select"
+ class="w-full appearance-none bg-white/80 dark:bg-black/40 border border-black/10 dark:border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-orange-500/50 text-gray-900 dark:text-white transition-colors"
+ bind:value={selectedForgeVersion}
+ >
+ {#each forgeVersions as version}
+ <option value={version.version}>
+ {version.version}
+ {version.recommended ? "⭐ recommended" : ""}
+ {version.latest ? "(latest)" : ""}
+ </option>
+ {/each}
+ </select>
+ <div class="absolute right-4 top-1/2 -translate-y-1/2 text-black/30 dark:text-white/20 pointer-events-none">▼</div>
+ </div>
+ </div>
+
+ <button
+ class="w-full bg-orange-600 hover:bg-orange-500 text-white py-3 px-4 rounded-xl font-bold text-sm transition-all shadow-lg shadow-orange-500/20 disabled:opacity-50 disabled:shadow-none hover:scale-[1.02] active:scale-[0.98]"
+ onclick={installModLoader}
+ disabled={isLoading || !selectedForgeVersion}
+ >
+ Install Forge {selectedForgeVersion}
+ </button>
+ {/if}
+ </div>
+ {/if}
+ </div>
+</div>
diff --git a/ui/src/components/ParticleBackground.svelte b/ui/src/components/ParticleBackground.svelte
new file mode 100644
index 0000000..080f1f2
--- /dev/null
+++ b/ui/src/components/ParticleBackground.svelte
@@ -0,0 +1,57 @@
+<script lang="ts">
+ import { onMount, onDestroy } from "svelte";
+ import { ConstellationEffect } from "../lib/effects/ConstellationEffect";
+ import { SaturnEffect } from "../lib/effects/SaturnEffect";
+ import { settingsState } from "../stores/settings.svelte";
+
+ let canvas: HTMLCanvasElement;
+ let activeEffect: any;
+
+ function loadEffect() {
+ if (activeEffect) {
+ activeEffect.destroy();
+ }
+
+ if (!canvas) return;
+
+ if (settingsState.settings.active_effect === "saturn") {
+ activeEffect = new SaturnEffect(canvas);
+ } else {
+ activeEffect = new ConstellationEffect(canvas);
+ }
+
+ // Ensure correct size immediately
+ activeEffect.resize(window.innerWidth, window.innerHeight);
+ }
+
+ $effect(() => {
+ const _ = settingsState.settings.active_effect;
+ if (canvas) {
+ loadEffect();
+ }
+ });
+
+ onMount(() => {
+ const resizeObserver = new ResizeObserver(() => {
+ if (canvas && activeEffect) {
+ activeEffect.resize(window.innerWidth, window.innerHeight);
+ }
+ });
+
+ resizeObserver.observe(document.body);
+
+ return () => {
+ resizeObserver.disconnect();
+ if (activeEffect) activeEffect.destroy();
+ };
+ });
+
+ onDestroy(() => {
+ if (activeEffect) activeEffect.destroy();
+ });
+</script>
+
+<canvas
+ bind:this={canvas}
+ class="absolute inset-0 z-0 pointer-events-none"
+></canvas>
diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte
index 9f260c1..86bcce1 100644
--- a/ui/src/components/SettingsView.svelte
+++ b/ui/src/components/SettingsView.svelte
@@ -1,126 +1,296 @@
<script lang="ts">
import { settingsState } from "../stores/settings.svelte";
+ import { open } from "@tauri-apps/plugin-dialog";
+ import { convertFileSrc } from "@tauri-apps/api/core";
+
+ async function selectBackground() {
+ try {
+ const selected = await open({
+ multiple: false,
+ filters: [
+ {
+ name: "Images",
+ extensions: ["png", "jpg", "jpeg", "webp", "gif"],
+ },
+ ],
+ });
+
+ if (selected && typeof selected === "string") {
+ settingsState.settings.custom_background_path = selected;
+ settingsState.saveSettings();
+ }
+ } catch (e) {
+ console.error("Failed to select background:", e);
+ }
+ }
+
+ function clearBackground() {
+ settingsState.settings.custom_background_path = undefined;
+ settingsState.saveSettings();
+ }
</script>
-<div class="p-8 bg-zinc-900 h-full overflow-y-auto">
- <h2 class="text-3xl font-bold mb-8">Settings</h2>
+<div class="h-full flex flex-col p-6 overflow-hidden">
+ <div class="flex items-center justify-between mb-6">
+ <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">Settings</h2>
+ </div>
- <div class="space-y-6 max-w-2xl">
- <!-- Java Path -->
- <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700">
- <label
- for="java-path"
- class="block text-sm font-bold text-zinc-400 mb-2 uppercase tracking-wide"
- >Java Executable Path</label
- >
- <div class="flex gap-2">
- <input
- id="java-path"
- bind:value={settingsState.settings.java_path}
- type="text"
- class="bg-zinc-950 text-white flex-1 p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none font-mono text-sm"
- placeholder="e.g. java, /usr/bin/java"
- />
- <button
- onclick={() => settingsState.detectJava()}
- disabled={settingsState.isDetectingJava}
- class="bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-white px-4 py-2 rounded transition-colors whitespace-nowrap"
- >
- {settingsState.isDetectingJava ? "Detecting..." : "Auto Detect"}
- </button>
+ <div class="flex-1 overflow-y-auto pr-2 space-y-6 custom-scrollbar pb-10">
+
+ <!-- Appearance / Background -->
+ <div class="dark:bg-black/20 bg-white/60 p-6 rounded-2xl border dark:border-white/5 border-black/5 shadow-sm backdrop-blur-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-6 flex items-center gap-2">
+ Appearance
+ </h3>
+
+ <div class="space-y-4">
+ <div>
+ <label class="block text-sm font-medium dark:text-white/70 text-black/70 mb-3">Custom Background Image</label>
+
+ <div class="flex items-center gap-6">
+ <!-- Preview -->
+ <div class="w-40 h-24 rounded-xl overflow-hidden dark:bg-black/50 bg-gray-200 border dark:border-white/10 border-black/10 relative group shadow-lg">
+ {#if settingsState.settings.custom_background_path}
+ <img
+ src={convertFileSrc(settingsState.settings.custom_background_path)}
+ alt="Background Preview"
+ class="w-full h-full object-cover"
+ />
+ {:else}
+ <div class="w-full h-full bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950 opacity-100"></div>
+ <div class="absolute inset-0 flex items-center justify-center text-[10px] text-white/50 bg-black/20 ">Default Gradient</div>
+ {/if}
+ </div>
+
+ <!-- Actions -->
+ <div class="flex flex-col gap-2">
+ <button
+ onclick={selectBackground}
+ class="dark:bg-white/10 dark:hover:bg-white/20 bg-black/5 hover:bg-black/10 dark:text-white text-black px-4 py-2 rounded-lg text-sm transition-colors border dark:border-white/5 border-black/5"
+ >
+ Select Image
+ </button>
+
+ {#if settingsState.settings.custom_background_path}
+ <button
+ onclick={clearBackground}
+ class="text-red-400 hover:text-red-300 text-sm px-4 py-1 text-left transition-colors"
+ >
+ Reset to Default
+ </button>
+ {/if}
+ </div>
+ </div>
+ <p class="text-xs dark:text-white/30 text-black/40 mt-3">
+ Select an image from your computer to replace the default gradient background.
+ Supported formats: PNG, JPG, WEBP, GIF.
+ </p>
+ </div>
+
+ <!-- Visual Settings -->
+ <div class="pt-4 border-t dark:border-white/5 border-black/5 space-y-4">
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="visual-effects-label">Visual Effects</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Enable particle effects and animated gradients. (Default: On)</p>
+ </div>
+ <button
+ aria-labelledby="visual-effects-label"
+ onclick={() => { settingsState.settings.enable_visual_effects = !settingsState.settings.enable_visual_effects; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.enable_visual_effects ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.enable_visual_effects ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ {#if settingsState.settings.enable_visual_effects}
+ <div class="flex items-center justify-between pl-2 border-l-2 dark:border-white/5 border-black/5 ml-1">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="theme-effect-label">Theme Effect</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Select the active visual theme.</p>
+ </div>
+ <select
+ aria-labelledby="theme-effect-label"
+ bind:value={settingsState.settings.active_effect}
+ onchange={() => settingsState.saveSettings()}
+ class="dark:bg-black/40 bg-white dark:text-white text-black text-xs px-3 py-2 rounded-lg border dark:border-white/10 border-black/10 outline-none focus:border-indigo-500/50 appearance-none cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
+ >
+ <option value="saturn">Saturn (Saturn)</option>
+ <option value="constellation">Network (Constellation)</option>
+ </select>
+ </div>
+ {/if}
+
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="gpu-acceleration-label">GPU Acceleration</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Enable GPU acceleration for the interface. (Default: Off, Requires Restart)</p>
+ </div>
+ <button
+ aria-labelledby="gpu-acceleration-label"
+ onclick={() => { settingsState.settings.enable_gpu_acceleration = !settingsState.settings.enable_gpu_acceleration; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.enable_gpu_acceleration ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.enable_gpu_acceleration ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ <!-- Color Theme Switcher -->
+ <div class="flex items-center justify-between pt-4 border-t dark:border-white/5 border-black/5 opacity-50 cursor-not-allowed">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="color-theme-label">Color Theme</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Interface color mode. (Locked to Dark)</p>
+ </div>
+ <div class="flex items-center bg-black/5 dark:bg-white/5 rounded-lg p-1 pointer-events-none">
+ <button
+ disabled
+ class="px-3 py-1 rounded-md text-xs font-medium transition-all text-gray-500 dark:text-gray-600"
+ >
+ Light
+ </button>
+ <button
+ disabled
+ class="px-3 py-1 rounded-md text-xs font-medium transition-all bg-indigo-500 text-white shadow-sm"
+ >
+ Dark
+ </button>
+ </div>
+ </div>
+ </div>
</div>
+ </div>
+
+ <!-- Java Path -->
+ <div class="dark:bg-black/20 bg-white/60 p-6 rounded-2xl border dark:border-white/5 border-black/5 shadow-sm backdrop-blur-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ Java Environment
+ </h3>
+ <div class="space-y-4">
+ <div>
+ <label for="java-path" class="block text-sm font-medium text-white/70 mb-2">Java Executable Path</label>
+ <div class="flex gap-2">
+ <input
+ id="java-path"
+ bind:value={settingsState.settings.java_path}
+ type="text"
+ class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
+ placeholder="e.g. java, /usr/bin/java"
+ />
+ <button
+ onclick={() => settingsState.detectJava()}
+ disabled={settingsState.isDetectingJava}
+ class="bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white px-4 py-2 rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium"
+ >
+ {settingsState.isDetectingJava ? "Detecting..." : "Auto Detect"}
+ </button>
+ </div>
+ </div>
{#if settingsState.javaInstallations.length > 0}
<div class="mt-4 space-y-2">
- <p class="text-xs text-zinc-400 uppercase font-bold">Detected Java Installations:</p>
+ <p class="text-[10px] text-white/30 uppercase font-bold tracking-wider">Detected Installations</p>
{#each settingsState.javaInstallations as java}
<button
onclick={() => settingsState.selectJava(java.path)}
- class="w-full text-left p-3 bg-zinc-950 rounded border transition-colors {settingsState.settings.java_path === java.path ? 'border-indigo-500 bg-indigo-950/30' : 'border-zinc-700 hover:border-zinc-500'}"
+ class="w-full text-left p-3 rounded-lg border transition-all duration-200 group
+ {settingsState.settings.java_path === java.path
+ ? 'bg-indigo-500/20 border-indigo-500/30'
+ : 'bg-black/20 border-white/5 hover:bg-white/5 hover:border-white/10'}"
>
<div class="flex justify-between items-center">
<div>
- <span class="text-white font-mono text-sm">{java.version}</span>
- <span class="text-zinc-500 text-xs ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span>
+ <span class="text-white font-mono text-xs font-bold">{java.version}</span>
+ <span class="text-white/40 text-[10px] ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span>
</div>
{#if settingsState.settings.java_path === java.path}
- <span class="text-indigo-400 text-xs">Selected</span>
+ <span class="text-indigo-300 text-[10px] font-bold uppercase tracking-wider">Selected</span>
{/if}
</div>
- <div class="text-zinc-500 text-xs font-mono truncate mt-1">{java.path}</div>
+ <div class="text-white/30 text-[10px] font-mono truncate mt-1 group-hover:text-white/50">{java.path}</div>
</button>
{/each}
</div>
{/if}
-
- <p class="text-xs text-zinc-500 mt-2">
- The command or path to the Java Runtime Environment. Click "Auto Detect" to find installed Java versions.
- </p>
+ </div>
</div>
<!-- Memory -->
- <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700">
- <h3
- class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide"
- >Memory Allocation (RAM)</h3>
-
+ <div class="bg-black/20 p-6 rounded-2xl border border-white/5 ">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ Memory Allocation (RAM)
+ </h3>
<div class="grid grid-cols-2 gap-6">
<div>
- <label for="min-memory" class="block text-xs text-zinc-500 mb-1"
- >Minimum (MB)</label
- >
+ <label for="min-memory" class="block text-sm font-medium text-white/70 mb-2">Minimum (MB)</label>
<input
id="min-memory"
bind:value={settingsState.settings.min_memory}
type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
/>
</div>
<div>
- <label for="max-memory" class="block text-xs text-zinc-500 mb-1"
- >Maximum (MB)</label
- >
+ <label for="max-memory" class="block text-sm font-medium text-white/70 mb-2">Maximum (MB)</label>
<input
id="max-memory"
bind:value={settingsState.settings.max_memory}
type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
/>
</div>
</div>
</div>
<!-- Resolution -->
- <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700">
- <h3
- class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide"
- >Game Window Size</h3>
+ <div class="bg-black/20 p-6 rounded-2xl border border-white/5 ">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ Game Window Size
+ </h3>
<div class="grid grid-cols-2 gap-6">
<div>
- <label for="window-width" class="block text-xs text-zinc-500 mb-1">Width</label>
+ <label for="window-width" class="block text-sm font-medium text-white/70 mb-2">Width</label>
<input
id="window-width"
bind:value={settingsState.settings.width}
type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
/>
</div>
<div>
- <label for="window-height" class="block text-xs text-zinc-500 mb-1">Height</label>
+ <label for="window-height" class="block text-sm font-medium text-white/70 mb-2">Height</label>
<input
id="window-height"
bind:value={settingsState.settings.height}
type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
/>
</div>
</div>
</div>
- <div class="pt-4">
+ <!-- Download Settings -->
+ <div class="bg-black/20 p-6 rounded-2xl border border-white/5 ">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ Network
+ </h3>
+ <div>
+ <label for="download-threads" class="block text-sm font-medium text-white/70 mb-2">Concurrent Download Threads</label>
+ <input
+ id="download-threads"
+ bind:value={settingsState.settings.download_threads}
+ type="number"
+ min="1"
+ max="128"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
+ />
+ <p class="text-xs text-white/30 mt-2">Higher values usually mean faster downloads but use more CPU/Network.</p>
+ </div>
+ </div>
+
+ <div class="pt-4 flex justify-end">
<button
onclick={() => settingsState.saveSettings()}
- class="bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-8 rounded shadow-lg transition-transform active:scale-95"
+ class="bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-500 hover:to-green-500 text-white font-bold py-3 px-8 rounded-xl shadow-lg shadow-emerald-500/20 transition-all hover:scale-105 active:scale-95"
>
Save Settings
</button>
diff --git a/ui/src/components/Sidebar.svelte b/ui/src/components/Sidebar.svelte
index a4f4e35..e6fbf43 100644
--- a/ui/src/components/Sidebar.svelte
+++ b/ui/src/components/Sidebar.svelte
@@ -3,64 +3,56 @@
</script>
<aside
- class="w-20 lg:w-64 bg-zinc-950 flex flex-col items-center lg:items-start transition-all duration-300 border-r border-zinc-800 shrink-0"
+ class="w-20 lg:w-64 dark:bg-black bg-white/80 border-r dark:border-white/5 border-gray-200/50 flex flex-col items-center lg:items-start transition-all duration-300 shrink-0 py-6 backdrop-blur-md"
>
+ <!-- Logo Area -->
<div
- class="h-20 w-full flex items-center justify-center lg:justify-start lg:px-6 border-b border-zinc-800/50"
+ class="h-16 w-full flex items-center justify-center lg:justify-start lg:px-8 mb-6"
>
- <!-- Icon Logo (Visible on small) -->
+ <!-- Icon Logo (Small) -->
<div
- class="lg:hidden text-2xl font-black bg-clip-text text-transparent bg-gradient-to-tr from-indigo-400 to-purple-400"
+ class="lg:hidden text-3xl font-black bg-clip-text text-transparent bg-gradient-to-tr from-indigo-400 to-fuchsia-400 drop-shadow-lg"
>
D
</div>
- <!-- Full Logo (Visible on large) -->
+ <!-- Full Logo (Large) -->
<div
- class="hidden lg:block font-bold text-xl tracking-wider text-indigo-400"
+ class="hidden lg:block font-bold text-2xl tracking-wider dark:text-white text-gray-900"
>
- DROP<span class="text-white">OUT</span>
+ <span class="bg-clip-text text-transparent bg-gradient-to-r from-indigo-400 to-fuchsia-400">DROP</span>OUT
</div>
</div>
- <nav class="flex-1 w-full flex flex-col gap-2 p-3">
- <button
- class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView ===
- 'home'
- ? 'bg-zinc-800/80 text-white'
- : 'text-zinc-400'} transition-all relative"
- onclick={() => uiState.setView("home")}
- >
- <span class="text-xl relative z-10">🏠</span>
- <span
- class="hidden lg:block font-medium relative z-10 transition-opacity"
- >Home</span
+ <!-- Navigation -->
+ <nav class="flex-1 w-full flex flex-col gap-3 px-3">
+ <!-- Nav Item Helper -->
+ {#snippet navItem(view, icon, label)}
+ <button
+ class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-5 py-3.5 rounded-xl transition-all duration-200 relative overflow-hidden
+ {uiState.currentView === view
+ ? 'bg-gradient-to-r from-indigo-500/20 to-purple-500/20 dark:text-white text-indigo-900 shadow-lg shadow-indigo-500/10 dark:border border-white/10'
+ : 'dark:text-zinc-400 text-gray-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
+ onclick={() => uiState.setView(view)}
>
- </button>
- <button
- class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView ===
- 'versions'
- ? 'bg-zinc-800/80 text-white'
- : 'text-zinc-400'} transition-all"
- onclick={() => uiState.setView("versions")}
- >
- <span class="text-xl">📦</span>
- <span class="hidden lg:block font-medium">Versions</span>
- </button>
- <button
- class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView ===
- 'settings'
- ? 'bg-zinc-800/80 text-white'
- : 'text-zinc-400'} transition-all"
- onclick={() => uiState.setView("settings")}
- >
- <span class="text-xl">⚙️</span>
- <span class="hidden lg:block font-medium">Settings</span>
- </button>
+ <span class="text-xl relative z-10 transition-transform group-hover:scale-110 duration-200">{icon}</span>
+ <span class="hidden lg:block font-medium relative z-10">{label}</span>
+
+ <!-- Active Indicator Line -->
+ {#if uiState.currentView === view}
+ <div class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-indigo-500 rounded-r-full lg:hidden"></div>
+ {/if}
+ </button>
+ {/snippet}
+
+ {@render navItem('home', '🏠', 'Home')}
+ {@render navItem('versions', '📦', 'Versions')}
+ {@render navItem('settings', '⚙️', 'Settings')}
</nav>
+ <!-- Footer Info -->
<div
- class="p-4 w-full border-t border-zinc-800 flex justify-center lg:justify-start"
+ class="p-4 w-full flex justify-center lg:justify-start lg:px-8 opacity-50 hover:opacity-100 transition-opacity"
>
- <div class="text-xs text-zinc-600 font-mono">v{uiState.appVersion}</div>
+ <div class="text-xs font-mono tracking-widest text-zinc-500">v{uiState.appVersion}</div>
</div>
</aside>
diff --git a/ui/src/components/StatusToast.svelte b/ui/src/components/StatusToast.svelte
index b1feffc..4c981c7 100644
--- a/ui/src/components/StatusToast.svelte
+++ b/ui/src/components/StatusToast.svelte
@@ -3,28 +3,34 @@
</script>
{#if uiState.status !== "Ready"}
- <div
- class="absolute top-12 right-12 bg-zinc-800/90 backdrop-blur border border-zinc-600 p-4 rounded-lg shadow-2xl max-w-sm animate-in fade-in slide-in-from-top-4 duration-300 z-50 group"
- >
- <div class="flex justify-between items-start mb-1">
- <div class="text-xs text-zinc-400 uppercase font-bold">Status</div>
- <button
- onclick={() => uiState.setStatus("Ready")}
- class="text-zinc-500 hover:text-white transition -mt-1 -mr-1 p-1"
- >
- ✕
- </button>
+ {#key uiState.status}
+ <div
+ class="absolute top-12 right-12 bg-white/90 dark:bg-zinc-800/90 backdrop-blur border border-zinc-200 dark:border-zinc-600 p-4 rounded-lg shadow-2xl max-w-sm animate-in fade-in slide-in-from-top-4 duration-300 z-50 group"
+ >
+ <div class="flex justify-between items-start mb-1">
+ <div class="text-xs text-zinc-500 dark:text-zinc-400 uppercase font-bold">Status</div>
+ <button
+ onclick={() => uiState.setStatus("Ready")}
+ class="text-zinc-400 hover:text-black dark:text-zinc-500 dark:hover:text-white transition -mt-1 -mr-1 p-1"
+ >
+ ✕
+ </button>
+ </div>
+ <div class="font-mono text-sm whitespace-pre-wrap mb-2 text-gray-900 dark:text-gray-100">{uiState.status}</div>
+ <div class="w-full bg-gray-200 dark:bg-zinc-700/50 h-1 rounded-full overflow-hidden">
+ <div
+ class="h-full bg-indigo-500 origin-left w-full progress-bar"
+ ></div>
+ </div>
</div>
- <div class="font-mono text-sm whitespace-pre-wrap mb-2">{uiState.status}</div>
- <div class="w-full bg-zinc-700/50 h-1 rounded-full overflow-hidden">
- <div
- class="h-full bg-indigo-500 animate-[progress_5s_linear_forwards] origin-left w-full"
- ></div>
- </div>
- </div>
+ {/key}
{/if}
<style>
+ .progress-bar {
+ animation: progress 5s linear forwards;
+ }
+
@keyframes progress {
from {
transform: scaleX(1);
diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte
index 8c0ddfe..8f3a568 100644
--- a/ui/src/components/VersionsView.svelte
+++ b/ui/src/components/VersionsView.svelte
@@ -1,34 +1,237 @@
<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
import { gameState } from "../stores/game.svelte";
+ import ModLoaderSelector from "./ModLoaderSelector.svelte";
+
+ let searchQuery = $state("");
+ let normalizedQuery = $derived(
+ searchQuery.trim().toLowerCase().replace(/。/g, ".")
+ );
+
+ // Filter by version type
+ let typeFilter = $state<"all" | "release" | "snapshot" | "modded">("all");
+
+ // Installed modded versions
+ let installedFabricVersions = $state<string[]>([]);
+ let isLoadingModded = $state(false);
+
+ // Load installed modded versions
+ async function loadInstalledModdedVersions() {
+ isLoadingModded = true;
+ try {
+ installedFabricVersions = await invoke<string[]>(
+ "list_installed_fabric_versions"
+ );
+ } catch (e) {
+ console.error("Failed to load installed fabric versions:", e);
+ } finally {
+ isLoadingModded = false;
+ }
+ }
+
+ // Load on mount
+ $effect(() => {
+ loadInstalledModdedVersions();
+ });
+
+ // Combined versions list (vanilla + modded)
+ let allVersions = $derived(() => {
+ const moddedVersions = installedFabricVersions.map((id) => ({
+ id,
+ type: "fabric",
+ url: "",
+ time: "",
+ releaseTime: new Date().toISOString(),
+ }));
+ return [...moddedVersions, ...gameState.versions];
+ });
+
+ let filteredVersions = $derived(() => {
+ let versions = allVersions();
+
+ // Apply type filter
+ if (typeFilter === "release") {
+ versions = versions.filter((v) => v.type === "release");
+ } else if (typeFilter === "snapshot") {
+ versions = versions.filter((v) => v.type === "snapshot");
+ } else if (typeFilter === "modded") {
+ versions = versions.filter(
+ (v) => v.type === "fabric" || v.type === "forge"
+ );
+ }
+
+ // Apply search filter
+ if (normalizedQuery.length > 0) {
+ versions = versions.filter((v) =>
+ v.id.toLowerCase().includes(normalizedQuery)
+ );
+ }
+
+ return versions;
+ });
+
+ function getVersionBadge(type: string) {
+ switch (type) {
+ case "release":
+ return { text: "Release", class: "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-300 dark:border-emerald-500/30" };
+ case "snapshot":
+ return { text: "Snapshot", class: "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/20 dark:text-amber-300 dark:border-amber-500/30" };
+ case "fabric":
+ return { text: "Fabric", class: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:border-indigo-500/30" };
+ case "forge":
+ return { text: "Forge", class: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-300 dark:border-orange-500/30" };
+ default:
+ return { text: type, class: "bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-zinc-500/20 dark:text-zinc-300 dark:border-zinc-500/30" };
+ }
+ }
+
+ function handleModLoaderInstall(versionId: string) {
+ // Refresh the installed versions list
+ loadInstalledModdedVersions();
+ // Select the newly installed version
+ gameState.selectedVersion = versionId;
+ }
+
+ // Get the base Minecraft version from selected version (for mod loader selector)
+ let selectedBaseVersion = $derived(() => {
+ const selected = gameState.selectedVersion;
+ if (!selected) return "";
+
+ // If it's a modded version, extract the base version
+ if (selected.startsWith("fabric-loader-")) {
+ // Format: fabric-loader-X.X.X-1.20.4
+ const parts = selected.split("-");
+ return parts[parts.length - 1];
+ }
+ if (selected.includes("-forge-")) {
+ // Format: 1.20.4-forge-49.0.38
+ return selected.split("-forge-")[0];
+ }
+
+ // Check if it's a valid vanilla version
+ const version = gameState.versions.find((v) => v.id === selected);
+ return version ? selected : "";
+ });
</script>
-<div class="p-8 h-full overflow-y-auto bg-zinc-900">
- <h2 class="text-3xl font-bold mb-6">Versions</h2>
- <div class="grid gap-2">
- {#if gameState.versions.length === 0}
- <div class="text-zinc-500">Loading versions...</div>
- {:else}
- {#each gameState.versions as version}
- <button
- class="flex items-center justify-between p-4 bg-zinc-800 rounded hover:bg-zinc-700 transition text-left border border-zinc-700 {gameState.selectedVersion ===
- version.id
- ? 'border-green-500 bg-zinc-800/80 ring-1 ring-green-500'
- : ''}"
- onclick={() => (gameState.selectedVersion = version.id)}
- >
- <div>
- <div class="font-bold font-mono text-lg">{version.id}</div>
- <div class="text-xs text-zinc-400 capitalize">
- {version.type} • {new Date(
- version.releaseTime
- ).toLocaleDateString()}
- </div>
+<div class="h-full flex flex-col p-6 overflow-hidden">
+ <div class="flex items-center justify-between mb-6">
+ <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/60">Version Manager</h2>
+ <div class="text-sm dark:text-white/40 text-black/50">Select a version to play or modify</div>
+ </div>
+
+ <div class="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 overflow-hidden">
+ <!-- Left: Version List -->
+ <div class="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
+ <!-- Search and Filters (Glass Bar) -->
+ <div class="flex gap-3">
+ <div class="relative flex-1">
+ <span class="absolute left-3 top-1/2 -translate-y-1/2 dark:text-white/30 text-black/30">🔍</span>
+ <input
+ type="text"
+ placeholder="Search versions..."
+ class="w-full pl-9 pr-4 py-3 bg-white/60 dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-xl dark:text-white text-gray-900 placeholder-black/30 dark:placeholder-white/30 focus:outline-none focus:border-indigo-500/50 dark:focus:bg-black/40 focus:bg-white/80 transition-all backdrop-blur-sm"
+ bind:value={searchQuery}
+ />
+ </div>
+ </div>
+
+ <!-- Type Filter Tabs (Glass Caps) -->
+ <div class="flex p-1 bg-white/60 dark:bg-black/20 rounded-xl border border-black/5 dark:border-white/5">
+ {#each ['all', 'release', 'snapshot', 'modded'] as filter}
+ <button
+ class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 capitalize
+ {typeFilter === filter
+ ? 'bg-white shadow-sm border border-black/5 dark:bg-white/10 dark:text-white dark:shadow-lg dark:border-white/10 text-black'
+ : 'text-black/40 dark:text-white/40 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
+ onclick={() => (typeFilter = filter as any)}
+ >
+ {filter}
+ </button>
+ {/each}
+ </div>
+
+ <!-- Version List SCROLL -->
+ <div class="flex-1 overflow-y-auto pr-2 space-y-2 custom-scrollbar">
+ {#if gameState.versions.length === 0}
+ <div class="flex items-center justify-center h-40 dark:text-white/30 text-black/30 italic animate-pulse">
+ Fetching manifest...
</div>
- {#if gameState.selectedVersion === version.id}
- <div class="text-green-500 font-bold text-sm">SELECTED</div>
+ {:else if filteredVersions().length === 0}
+ <div class="flex flex-col items-center justify-center -40 dark:text-white/30 text-black/30 gap-2">
+ <span class="text-2xl">👻</span>
+ <span>No matching versions found</span>
+ </div>
+ {:else}
+ {#each filteredVersions() as version}
+ {@const badge = getVersionBadge(version.type)}
+ {@const isSelected = gameState.selectedVersion === version.id}
+ <button
+ class="w-full group flex items-center justify-between p-4 rounded-xl text-left border transition-all duration-200 relative overflow-hidden
+ {isSelected
+ ? 'bg-indigo-50 border-indigo-200 dark:bg-indigo-600/20 dark:border-indigo-500/50 shadow-[0_0_20px_rgba(99,102,241,0.2)]'
+ : 'bg-white/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/10 dark:hover:border-white/10 hover:translate-x-1'}"
+ onclick={() => (gameState.selectedVersion = version.id)}
+ >
+ <!-- Selection Glow -->
+ {#if isSelected}
+ <div class="absolute inset-0 bg-gradient-to-r from-indigo-500/10 to-transparent pointer-events-none"></div>
+ {/if}
+
+ <div class="relative z-10 flex items-center gap-4">
+ <span
+ class="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border {badge.class}"
+ >
+ {badge.text}
+ </span>
+ <div>
+ <div class="font-bold font-mono text-lg tracking-tight {isSelected ? 'text-black dark:text-white' : 'text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white'}">
+ {version.id}
+ </div>
+ {#if version.releaseTime && version.type !== "fabric" && version.type !== "forge"}
+ <div class="text-xs dark:text-white/30 text-black/30">
+ {new Date(version.releaseTime).toLocaleDateString()}
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ {#if isSelected}
+ <div class="relative z-10 text-indigo-500 dark:text-indigo-400">
+ <span class="text-lg">Selected</span>
+ </div>
+ {/if}
+ </button>
+ {/each}
+ {/if}
+ </div>
+ </div>
+
+ <!-- Right: Mod Loader Panel -->
+ <div class="flex flex-col gap-4">
+ <!-- Selected Version Info Card -->
+ <div class="bg-gradient-to-br from-white/40 to-white/20 dark:from-white/10 dark:to-white/5 p-6 rounded-2xl border border-black/5 dark:border-white/10 backdrop-blur-md relative overflow-hidden group">
+ <div class="absolute top-0 right-0 p-8 bg-indigo-500/20 blur-[60px] rounded-full group-hover:bg-indigo-500/30 transition-colors"></div>
+
+ <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-2 relative z-10">Current Selection</h3>
+ {#if gameState.selectedVersion}
+ <p class="font-mono text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/70 relative z-10 truncate">
+ {gameState.selectedVersion}
+ </p>
+ {:else}
+ <p class="dark:text-white/20 text-black/20 italic relative z-10">None selected</p>
{/if}
- </button>
- {/each}
- {/if}
+ </div>
+
+ <!-- Mod Loader Selector Card -->
+ <div class="bg-white/60 dark:bg-black/20 p-4 rounded-2xl border border-black/5 dark:border-white/5 backdrop-blur-sm flex-1 flex flex-col">
+ <ModLoaderSelector
+ selectedGameVersion={selectedBaseVersion()}
+ onInstall={handleModLoaderInstall}
+ />
+ </div>
+
+ </div>
</div>
</div>
+
diff --git a/ui/src/lib/DownloadMonitor.svelte b/ui/src/lib/DownloadMonitor.svelte
index b796591..860952c 100644
--- a/ui/src/lib/DownloadMonitor.svelte
+++ b/ui/src/lib/DownloadMonitor.svelte
@@ -9,11 +9,16 @@
downloaded: number; // in bytes
total: number; // in bytes
status: string;
+ completed_files: number;
+ total_files: number;
+ total_downloaded_bytes: number;
}
let currentFile = "";
- let progress = 0; // percentage 0-100
+ let progress = 0; // percentage 0-100 (current file)
+ let totalProgress = 0; // percentage 0-100 (all files)
let totalFiles = 0;
+ let completedFiles = 0;
let statusText = "Preparing...";
let unlistenProgress: () => void;
let unlistenStart: () => void;
@@ -21,13 +26,30 @@
let downloadedBytes = 0;
let totalBytes = 0;
+ // Speed and ETA tracking
+ let downloadSpeed = 0; // bytes per second
+ let etaSeconds = 0;
+ let startTime = 0;
+ let totalDownloadedBytes = 0;
+ let lastUpdateTime = 0;
+ let lastTotalBytes = 0;
+
onMount(async () => {
unlistenStart = await listen<number>("download-start", (event) => {
visible = true;
totalFiles = event.payload;
+ completedFiles = 0;
progress = 0;
+ totalProgress = 0;
statusText = "Starting download...";
currentFile = "";
+ // Reset speed tracking
+ startTime = Date.now();
+ totalDownloadedBytes = 0;
+ downloadSpeed = 0;
+ etaSeconds = 0;
+ lastUpdateTime = Date.now();
+ lastTotalBytes = 0;
});
unlistenProgress = await listen<DownloadEvent>(
@@ -36,8 +58,7 @@
const payload = event.payload;
currentFile = payload.file;
- // Simple file progress for now. Global progress would require tracking all files.
- // For single file (Client jar), this is accurate.
+ // Current file progress
downloadedBytes = payload.downloaded;
totalBytes = payload.total;
@@ -46,12 +67,54 @@
if (payload.total > 0) {
progress = (payload.downloaded / payload.total) * 100;
}
+
+ // Total progress (all files)
+ completedFiles = payload.completed_files;
+ totalFiles = payload.total_files;
+ if (totalFiles > 0) {
+ const currentFileFraction =
+ payload.total > 0 ? payload.downloaded / payload.total : 0;
+ totalProgress = ((completedFiles + currentFileFraction) / totalFiles) * 100;
+ }
+
+ // Calculate download speed (using moving average)
+ totalDownloadedBytes = payload.total_downloaded_bytes;
+ const now = Date.now();
+ const timeDiff = (now - lastUpdateTime) / 1000; // seconds
+
+ if (timeDiff >= 0.5) { // Update speed every 0.5 seconds
+ const bytesDiff = totalDownloadedBytes - lastTotalBytes;
+ const instantSpeed = bytesDiff / timeDiff;
+ // Smooth the speed with exponential moving average
+ downloadSpeed = downloadSpeed === 0 ? instantSpeed : downloadSpeed * 0.7 + instantSpeed * 0.3;
+ lastUpdateTime = now;
+ lastTotalBytes = totalDownloadedBytes;
+ }
+
+ // Estimate remaining time
+ if (downloadSpeed > 0 && completedFiles < totalFiles) {
+ const remainingFiles = totalFiles - completedFiles;
+ let estimatedRemainingBytes: number;
+
+ if (completedFiles > 0) {
+ // Use average size of completed files to estimate remaining files
+ const avgBytesPerCompletedFile = totalDownloadedBytes / completedFiles;
+ estimatedRemainingBytes = avgBytesPerCompletedFile * remainingFiles;
+ } else {
+ // No completed files yet: estimate based only on current file's remaining bytes
+ estimatedRemainingBytes = Math.max(totalBytes - downloadedBytes, 0);
+ }
+ etaSeconds = estimatedRemainingBytes / downloadSpeed;
+ } else {
+ etaSeconds = 0;
+ }
}
);
unlistenComplete = await listen("download-complete", () => {
statusText = "Done!";
progress = 100;
+ totalProgress = 100;
setTimeout(() => {
visible = false;
}, 2000);
@@ -71,22 +134,58 @@
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
+
+ function formatSpeed(bytesPerSecond: number) {
+ if (bytesPerSecond === 0) return "-- /s";
+ return formatBytes(bytesPerSecond) + "/s";
+ }
+
+ function formatTime(seconds: number) {
+ if (seconds <= 0 || !isFinite(seconds)) return "--";
+ if (seconds < 60) return `${Math.round(seconds)}s`;
+ if (seconds < 3600) {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.round(seconds % 60);
+ return `${mins}m ${secs}s`;
+ }
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ return `${hours}h ${mins}m`;
+ }
</script>
{#if visible}
<div
- class="fixed bottom-28 right-8 z-50 w-80 bg-zinc-900/90 backdrop-blur-md border border-zinc-700 rounded-lg shadow-2xl p-4 animate-in slide-in-from-right-10 fade-in duration-300"
+ class="fixed bottom-28 right-8 z-50 w-80 bg-zinc-900/90 border border-zinc-700 rounded-lg shadow-2xl p-4 animate-in slide-in-from-right-10 fade-in duration-300"
>
<div class="flex items-center justify-between mb-2">
<h3 class="text-white font-bold text-sm">Downloads</h3>
<span class="text-xs text-zinc-400">{statusText}</span>
</div>
+ <!-- Total Progress Bar -->
+ <div class="mb-3">
+ <div class="flex justify-between text-[10px] text-zinc-400 mb-1">
+ <span>Total Progress</span>
+ <span>{completedFiles} / {totalFiles} files</span>
+ </div>
+ <div class="w-full bg-zinc-800 rounded-full h-2.5 overflow-hidden">
+ <div
+ class="bg-gradient-to-r from-blue-500 to-cyan-400 h-2.5 rounded-full transition-all duration-200"
+ style="width: {totalProgress}%"
+ ></div>
+ </div>
+ <div class="flex justify-between text-[10px] text-zinc-500 font-mono mt-0.5">
+ <span>{formatSpeed(downloadSpeed)} · ETA: {formatTime(etaSeconds)}</span>
+ <span>{completedFiles < totalFiles ? Math.floor(totalProgress) : 100}%</span>
+ </div>
+ </div>
+
<div class="text-xs text-zinc-300 truncate mb-1" title={currentFile}>
{currentFile || "Waiting..."}
</div>
- <!-- Progress Bar -->
+ <!-- Current File Progress Bar -->
<div class="w-full bg-zinc-800 rounded-full h-2 mb-2 overflow-hidden">
<div
class="bg-gradient-to-r from-green-500 to-emerald-400 h-2 rounded-full transition-all duration-200"
diff --git a/ui/src/lib/GameConsole.svelte b/ui/src/lib/GameConsole.svelte
index 281dc85..8d5e0ce 100644
--- a/ui/src/lib/GameConsole.svelte
+++ b/ui/src/lib/GameConsole.svelte
@@ -75,7 +75,7 @@
</script>
{#if visible}
-<div class="fixed bottom-0 left-0 right-0 h-64 bg-zinc-950/95 border-t border-zinc-700 backdrop-blur flex flex-col z-50 transition-transform duration-300 transform translate-y-0">
+<div class="fixed bottom-0 left-0 right-0 h-64 bg-zinc-950/95 border-t border-zinc-700 flex flex-col z-50 transition-transform duration-300 transform translate-y-0">
<div class="flex items-center justify-between px-4 py-2 border-b border-zinc-800 bg-zinc-900/50">
<div class="flex items-center gap-4">
<span class="text-xs font-bold text-zinc-400 uppercase tracking-widest">Logs</span>
diff --git a/ui/src/lib/effects/ConstellationEffect.ts b/ui/src/lib/effects/ConstellationEffect.ts
new file mode 100644
index 0000000..2cc702e
--- /dev/null
+++ b/ui/src/lib/effects/ConstellationEffect.ts
@@ -0,0 +1,163 @@
+
+export class ConstellationEffect {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ private width: number = 0;
+ private height: number = 0;
+ private particles: Particle[] = [];
+ private animationId: number = 0;
+ private mouseX: number = -1000;
+ private mouseY: number = -1000;
+
+ // Configuration
+ private readonly particleCount = 100;
+ private readonly connectionDistance = 150;
+ private readonly particleSpeed = 0.5;
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext("2d", { alpha: true })!;
+
+ // Bind methods
+ this.animate = this.animate.bind(this);
+ this.handleMouseMove = this.handleMouseMove.bind(this);
+
+ // Initial setup
+ this.resize(window.innerWidth, window.innerHeight);
+ this.initParticles();
+
+ // Mouse interaction
+ window.addEventListener("mousemove", this.handleMouseMove);
+
+ // Start animation
+ this.animate();
+ }
+
+ resize(width: number, height: number) {
+ const dpr = window.devicePixelRatio || 1;
+ this.width = width;
+ this.height = height;
+
+ this.canvas.width = width * dpr;
+ this.canvas.height = height * dpr;
+ this.canvas.style.width = `${width}px`;
+ this.canvas.style.height = `${height}px`;
+
+ this.ctx.scale(dpr, dpr);
+
+ // Re-initialize if screen size changes significantly to maintain density
+ if (this.particles.length === 0) {
+ this.initParticles();
+ }
+ }
+
+ private initParticles() {
+ this.particles = [];
+ // Adjust density based on screen area
+ const area = this.width * this.height;
+ const density = Math.floor(area / 15000); // 1 particle per 15000px²
+ const count = Math.min(Math.max(density, 50), 200); // Clamp between 50 and 200
+
+ for (let i = 0; i < count; i++) {
+ this.particles.push(new Particle(this.width, this.height, this.particleSpeed));
+ }
+ }
+
+ private handleMouseMove(e: MouseEvent) {
+ const rect = this.canvas.getBoundingClientRect();
+ this.mouseX = e.clientX - rect.left;
+ this.mouseY = e.clientY - rect.top;
+ }
+
+ animate() {
+ this.ctx.clearRect(0, 0, this.width, this.height);
+
+ // Update and draw particles
+ this.particles.forEach(p => {
+ p.update(this.width, this.height);
+ p.draw(this.ctx);
+ });
+
+ // Draw lines
+ this.drawConnections();
+
+ this.animationId = requestAnimationFrame(this.animate);
+ }
+
+ private drawConnections() {
+ this.ctx.lineWidth = 1;
+
+ for (let i = 0; i < this.particles.length; i++) {
+ const p1 = this.particles[i];
+
+ // Connect to mouse if close
+ const distMouse = Math.hypot(p1.x - this.mouseX, p1.y - this.mouseY);
+ if (distMouse < this.connectionDistance + 50) {
+ const alpha = 1 - (distMouse / (this.connectionDistance + 50));
+ this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.4})`; // Brighter near mouse
+ this.ctx.beginPath();
+ this.ctx.moveTo(p1.x, p1.y);
+ this.ctx.lineTo(this.mouseX, this.mouseY);
+ this.ctx.stroke();
+
+ // Gently attract to mouse
+ if (distMouse > 10) {
+ p1.x += (this.mouseX - p1.x) * 0.005;
+ p1.y += (this.mouseY - p1.y) * 0.005;
+ }
+ }
+
+ // Connect to other particles
+ for (let j = i + 1; j < this.particles.length; j++) {
+ const p2 = this.particles[j];
+ const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
+
+ if (dist < this.connectionDistance) {
+ const alpha = 1 - (dist / this.connectionDistance);
+ this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.15})`;
+ this.ctx.beginPath();
+ this.ctx.moveTo(p1.x, p1.y);
+ this.ctx.lineTo(p2.x, p2.y);
+ this.ctx.stroke();
+ }
+ }
+ }
+ }
+
+ destroy() {
+ cancelAnimationFrame(this.animationId);
+ window.removeEventListener("mousemove", this.handleMouseMove);
+ }
+}
+
+class Particle {
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ size: number;
+
+ constructor(w: number, h: number, speed: number) {
+ this.x = Math.random() * w;
+ this.y = Math.random() * h;
+ this.vx = (Math.random() - 0.5) * speed;
+ this.vy = (Math.random() - 0.5) * speed;
+ this.size = Math.random() * 2 + 1;
+ }
+
+ update(w: number, h: number) {
+ this.x += this.vx;
+ this.y += this.vy;
+
+ // Bounce off walls
+ if (this.x < 0 || this.x > w) this.vx *= -1;
+ if (this.y < 0 || this.y > h) this.vy *= -1;
+ }
+
+ draw(ctx: CanvasRenderingContext2D) {
+ ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
+ ctx.beginPath();
+ ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
+ ctx.fill();
+ }
+}
diff --git a/ui/src/lib/effects/SaturnEffect.ts b/ui/src/lib/effects/SaturnEffect.ts
new file mode 100644
index 0000000..8a1c11f
--- /dev/null
+++ b/ui/src/lib/effects/SaturnEffect.ts
@@ -0,0 +1,194 @@
+// Optimized Saturn Effect for low-end hardware
+// Uses TypedArrays for memory efficiency and reduced particle density
+
+export class SaturnEffect {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ private width: number = 0;
+ private height: number = 0;
+
+ // Data-oriented design for performance
+ // xyz: Float32Array where [i*3, i*3+1, i*3+2] corresponds to x, y, z
+ private xyz: Float32Array | null = null;
+ // types: Uint8Array where 0 = planet, 1 = ring
+ private types: Uint8Array | null = null;
+ private count: number = 0;
+
+ private animationId: number = 0;
+ private angle: number = 0;
+ private scaleFactor: number = 1;
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d', {
+ alpha: true,
+ desynchronized: false // default is usually fine, 'desynchronized' can help latency but might flicker
+ })!;
+
+ // Initial resize will set up everything
+ this.resize(window.innerWidth, window.innerHeight);
+ this.initParticles();
+
+ this.animate = this.animate.bind(this);
+ this.animate();
+ }
+
+ resize(width: number, height: number) {
+ const dpr = window.devicePixelRatio || 1;
+ this.width = width;
+ this.height = height;
+
+ this.canvas.width = width * dpr;
+ this.canvas.height = height * dpr;
+ this.canvas.style.width = `${width}px`;
+ this.canvas.style.height = `${height}px`;
+
+ this.ctx.scale(dpr, dpr);
+
+ // Dynamic scaling based on screen size
+ const minDim = Math.min(width, height);
+ this.scaleFactor = minDim * 0.45;
+ }
+
+ initParticles() {
+ // Significantly reduced particle count for CPU optimization
+ // Planet: 1800 -> 1000
+ // Rings: 5000 -> 2500
+ // Total approx 3500 vs 6800 previously (approx 50% reduction)
+ const planetCount = 1000;
+ const ringCount = 2500;
+ this.count = planetCount + ringCount;
+
+ // Use TypedArrays for better memory locality
+ this.xyz = new Float32Array(this.count * 3);
+ this.types = new Uint8Array(this.count);
+
+ let idx = 0;
+
+ // 1. Planet
+ for (let i = 0; i < planetCount; i++) {
+ const theta = Math.random() * Math.PI * 2;
+ const phi = Math.acos((Math.random() * 2) - 1);
+ const r = 1.0;
+
+ // x, y, z
+ this.xyz[idx * 3] = r * Math.sin(phi) * Math.cos(theta);
+ this.xyz[idx * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
+ this.xyz[idx * 3 + 2] = r * Math.cos(phi);
+
+ this.types[idx] = 0; // 0 for planet
+ idx++;
+ }
+
+ // 2. Rings
+ const ringInner = 1.4;
+ const ringOuter = 2.3;
+
+ for (let i = 0; i < ringCount; i++) {
+ const angle = Math.random() * Math.PI * 2;
+ const dist = Math.sqrt(Math.random() * (ringOuter*ringOuter - ringInner*ringInner) + ringInner*ringInner);
+
+ // x, y, z
+ this.xyz[idx * 3] = dist * Math.cos(angle);
+ this.xyz[idx * 3 + 1] = (Math.random() - 0.5) * 0.05;
+ this.xyz[idx * 3 + 2] = dist * Math.sin(angle);
+
+ this.types[idx] = 1; // 1 for ring
+ idx++;
+ }
+ }
+
+ animate() {
+ this.ctx.clearRect(0, 0, this.width, this.height);
+
+ // Normal blending
+ this.ctx.globalCompositeOperation = 'source-over';
+
+ // Slower rotation (from 0.0015 to 0.0005)
+ this.angle += 0.0005;
+
+ const cx = this.width * 0.6;
+ const cy = this.height * 0.5;
+
+ // Pre-calculate rotation matrices
+ const rotationY = this.angle;
+ const rotationX = 0.4;
+ const rotationZ = 0.15;
+
+ const sinY = Math.sin(rotationY);
+ const cosY = Math.cos(rotationY);
+ const sinX = Math.sin(rotationX);
+ const cosX = Math.cos(rotationX);
+ const sinZ = Math.sin(rotationZ);
+ const cosZ = Math.cos(rotationZ);
+
+ const fov = 1500;
+ const scaleFactor = this.scaleFactor;
+
+ if (!this.xyz || !this.types) return;
+
+ for (let i = 0; i < this.count; i++) {
+ const x = this.xyz[i * 3];
+ const y = this.xyz[i * 3 + 1];
+ const z = this.xyz[i * 3 + 2];
+
+ // Apply Scale
+ const px = x * scaleFactor;
+ const py = y * scaleFactor;
+ const pz = z * scaleFactor;
+
+ // 1. Rotate Y
+ const x1 = px * cosY - pz * sinY;
+ const z1 = pz * cosY + px * sinY;
+ // y1 = py
+
+ // 2. Rotate X
+ const y2 = py * cosX - z1 * sinX;
+ const z2 = z1 * cosX + py * sinX;
+ // x2 = x1
+
+ // 3. Rotate Z
+ const x3 = x1 * cosZ - y2 * sinZ;
+ const y3 = y2 * cosZ + x1 * sinZ;
+ const z3 = z2;
+
+ const scale = fov / (fov + z3);
+
+ if (z3 > -fov) {
+ const x2d = cx + x3 * scale;
+ const y2d = cy + y3 * scale;
+
+ // Size calculation - slightly larger dots to compensate for lower count
+ // Previously Planet 2.0 -> 2.4, Ring 1.3 -> 1.5
+ const type = this.types[i];
+ const sizeBase = type === 0 ? 2.4 : 1.5;
+ const size = sizeBase * scale;
+
+ // Opacity
+ let alpha = (scale * scale * scale);
+ if (alpha > 1) alpha = 1;
+ if (alpha < 0.15) continue; // Skip very faint particles for performance
+
+ // Optimization: Planet color vs Ring color
+ if (type === 0) {
+ // Planet: Warn White
+ this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`;
+ } else {
+ // Ring: Cool White
+ this.ctx.fillStyle = `rgba(220, 240, 255, ${alpha})`;
+ }
+
+ // Render as squares (fillRect) instead of circles (arc)
+ // This is significantly faster for software rendering and reduces GPU usage.
+ this.ctx.fillRect(x2d, y2d, size, size);
+ }
+ }
+
+ this.animationId = requestAnimationFrame(this.animate);
+ }
+
+ destroy() {
+ cancelAnimationFrame(this.animationId);
+ }
+}
+
diff --git a/ui/src/lib/modLoaderApi.ts b/ui/src/lib/modLoaderApi.ts
new file mode 100644
index 0000000..9d0d09d
--- /dev/null
+++ b/ui/src/lib/modLoaderApi.ts
@@ -0,0 +1,108 @@
+/**
+ * Mod Loader API service for Fabric and Forge integration.
+ * This module provides functions to interact with the Tauri backend
+ * for mod loader version management.
+ */
+
+import { invoke } from "@tauri-apps/api/core";
+import type {
+ FabricGameVersion,
+ FabricLoaderVersion,
+ FabricLoaderEntry,
+ InstalledFabricVersion,
+ ForgeVersion,
+ InstalledForgeVersion,
+} from "../types";
+
+// ==================== Fabric API ====================
+
+/**
+ * Get all Minecraft versions supported by Fabric.
+ */
+export async function getFabricGameVersions(): Promise<FabricGameVersion[]> {
+ return invoke<FabricGameVersion[]>("get_fabric_game_versions");
+}
+
+/**
+ * Get all available Fabric loader versions.
+ */
+export async function getFabricLoaderVersions(): Promise<FabricLoaderVersion[]> {
+ return invoke<FabricLoaderVersion[]>("get_fabric_loader_versions");
+}
+
+/**
+ * Get Fabric loaders available for a specific Minecraft version.
+ */
+export async function getFabricLoadersForVersion(
+ gameVersion: string
+): Promise<FabricLoaderEntry[]> {
+ return invoke<FabricLoaderEntry[]>("get_fabric_loaders_for_version", {
+ gameVersion,
+ });
+}
+
+/**
+ * Install Fabric loader for a specific Minecraft version.
+ */
+export async function installFabric(
+ gameVersion: string,
+ loaderVersion: string
+): Promise<InstalledFabricVersion> {
+ return invoke<InstalledFabricVersion>("install_fabric", {
+ gameVersion,
+ loaderVersion,
+ });
+}
+
+/**
+ * List all installed Fabric versions.
+ */
+export async function listInstalledFabricVersions(): Promise<string[]> {
+ return invoke<string[]>("list_installed_fabric_versions");
+}
+
+/**
+ * Check if Fabric is installed for a specific version combination.
+ */
+export async function isFabricInstalled(
+ gameVersion: string,
+ loaderVersion: string
+): Promise<boolean> {
+ return invoke<boolean>("is_fabric_installed", {
+ gameVersion,
+ loaderVersion,
+ });
+}
+
+// ==================== Forge API ====================
+
+/**
+ * Get all Minecraft versions supported by Forge.
+ */
+export async function getForgeGameVersions(): Promise<string[]> {
+ return invoke<string[]>("get_forge_game_versions");
+}
+
+/**
+ * Get Forge versions available for a specific Minecraft version.
+ */
+export async function getForgeVersionsForGame(
+ gameVersion: string
+): Promise<ForgeVersion[]> {
+ return invoke<ForgeVersion[]>("get_forge_versions_for_game", {
+ gameVersion,
+ });
+}
+
+/**
+ * Install Forge for a specific Minecraft version.
+ */
+export async function installForge(
+ gameVersion: string,
+ forgeVersion: string
+): Promise<InstalledForgeVersion> {
+ return invoke<InstalledForgeVersion>("install_forge", {
+ gameVersion,
+ forgeVersion,
+ });
+}
diff --git a/ui/src/stores/settings.svelte.ts b/ui/src/stores/settings.svelte.ts
index 989172c..b67cdc3 100644
--- a/ui/src/stores/settings.svelte.ts
+++ b/ui/src/stores/settings.svelte.ts
@@ -9,6 +9,11 @@ export class SettingsState {
java_path: "java",
width: 854,
height: 480,
+ download_threads: 32,
+ enable_gpu_acceleration: false,
+ enable_visual_effects: true,
+ active_effect: "constellation",
+ theme: "dark",
});
javaInstallations = $state<JavaInstallation[]>([]);
isDetectingJava = $state(false);
@@ -17,6 +22,11 @@ export class SettingsState {
try {
const result = await invoke<LauncherConfig>("get_settings");
this.settings = result;
+ // Force dark mode
+ if (this.settings.theme !== "dark") {
+ this.settings.theme = "dark";
+ this.saveSettings();
+ }
} catch (e) {
console.error("Failed to load settings:", e);
}
diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts
index b7ff0a0..7e2cc67 100644
--- a/ui/src/types/index.ts
+++ b/ui/src/types/index.ts
@@ -29,6 +29,12 @@ export interface LauncherConfig {
java_path: string;
width: number;
height: number;
+ download_threads: number;
+ custom_background_path?: string;
+ enable_gpu_acceleration: boolean;
+ enable_visual_effects: boolean;
+ active_effect: string;
+ theme: string;
}
export interface JavaInstallation {
@@ -36,3 +42,62 @@ export interface JavaInstallation {
version: string;
is_64bit: boolean;
}
+
+// ==================== Fabric Types ====================
+
+export interface FabricGameVersion {
+ version: string;
+ stable: boolean;
+}
+
+export interface FabricLoaderVersion {
+ separator: string;
+ build: number;
+ maven: string;
+ version: string;
+ stable: boolean;
+}
+
+export interface FabricLoaderEntry {
+ loader: FabricLoaderVersion;
+ intermediary: {
+ maven: string;
+ version: string;
+ stable: boolean;
+ };
+ launcherMeta: {
+ version: number;
+ mainClass: {
+ client: string;
+ server: string;
+ };
+ };
+}
+
+export interface InstalledFabricVersion {
+ id: string;
+ minecraft_version: string;
+ loader_version: string;
+ path: string;
+}
+
+// ==================== Forge Types ====================
+
+export interface ForgeVersion {
+ version: string;
+ minecraft_version: string;
+ recommended: boolean;
+ latest: boolean;
+}
+
+export interface InstalledForgeVersion {
+ id: string;
+ minecraft_version: string;
+ forge_version: string;
+ path: string;
+}
+
+// ==================== Mod Loader Type ====================
+
+export type ModLoaderType = "vanilla" | "fabric" | "forge";
+