diff options
| author | 2026-01-14 22:05:25 +0100 | |
|---|---|---|
| committer | 2026-01-14 22:05:25 +0100 | |
| commit | b473aa744e1382e946a92a116707b93151558888 (patch) | |
| tree | a8957a732caac948412c78ac7a443771f7ee12d0 /ui/src | |
| parent | 2cb21f2bbc601ae134095cf0e68b5bcc6966d227 (diff) | |
| parent | 18111ef323a81e399e3b907c9046170afcb8e0eb (diff) | |
| download | DropOut-b473aa744e1382e946a92a116707b93151558888.tar.gz DropOut-b473aa744e1382e946a92a116707b93151558888.zip | |
Merge main into feat/download-java-rt
- Integrate latest main branch changes (Fabric, Forge support, new UI)
- Keep Adoptium Java download feature with SHA256 support
- Merge improved download progress tracking with checksum verification
- Update dependencies and build configuration
Diffstat (limited to 'ui/src')
| -rw-r--r-- | ui/src/App.svelte | 919 | ||||
| -rw-r--r-- | ui/src/app.css | 2 | ||||
| -rw-r--r-- | ui/src/components/BottomBar.svelte | 79 | ||||
| -rw-r--r-- | ui/src/components/HomeView.svelte | 57 | ||||
| -rw-r--r-- | ui/src/components/LoginModal.svelte | 30 | ||||
| -rw-r--r-- | ui/src/components/ModLoaderSelector.svelte | 245 | ||||
| -rw-r--r-- | ui/src/components/ParticleBackground.svelte | 57 | ||||
| -rw-r--r-- | ui/src/components/SettingsView.svelte | 286 | ||||
| -rw-r--r-- | ui/src/components/Sidebar.svelte | 76 | ||||
| -rw-r--r-- | ui/src/components/StatusToast.svelte | 42 | ||||
| -rw-r--r-- | ui/src/components/VersionsView.svelte | 255 | ||||
| -rw-r--r-- | ui/src/lib/DownloadMonitor.svelte | 109 | ||||
| -rw-r--r-- | ui/src/lib/GameConsole.svelte | 2 | ||||
| -rw-r--r-- | ui/src/lib/effects/ConstellationEffect.ts | 163 | ||||
| -rw-r--r-- | ui/src/lib/effects/SaturnEffect.ts | 194 | ||||
| -rw-r--r-- | ui/src/lib/modLoaderApi.ts | 108 | ||||
| -rw-r--r-- | ui/src/stores/settings.svelte.ts | 10 | ||||
| -rw-r--r-- | ui/src/types/index.ts | 65 |
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"; + |