aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
author苏向夜 <fu050409@163.com>2026-01-19 14:17:32 +0800
committer苏向夜 <fu050409@163.com>2026-01-19 14:17:32 +0800
commitda0d79f0db873c08fab3bc85023167e174d18b0e (patch)
tree4a1934780d0d723ec8b834088188d4714f2cf3e7 /packages/ui/src
parent887e415314014c3da7db3048fa0e724f3078c5cb (diff)
downloadDropOut-da0d79f0db873c08fab3bc85023167e174d18b0e.tar.gz
DropOut-da0d79f0db873c08fab3bc85023167e174d18b0e.zip
chore(ui): refactor workspace to monorepo
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/App.svelte217
-rw-r--r--packages/ui/src/app.css167
-rw-r--r--packages/ui/src/assets/svelte.svg1
-rw-r--r--packages/ui/src/components/AssistantView.svelte436
-rw-r--r--packages/ui/src/components/BottomBar.svelte250
-rw-r--r--packages/ui/src/components/ConfigEditorModal.svelte369
-rw-r--r--packages/ui/src/components/CustomSelect.svelte173
-rw-r--r--packages/ui/src/components/HomeView.svelte271
-rw-r--r--packages/ui/src/components/InstanceCreationModal.svelte485
-rw-r--r--packages/ui/src/components/InstanceEditorModal.svelte439
-rw-r--r--packages/ui/src/components/InstancesView.svelte259
-rw-r--r--packages/ui/src/components/LoginModal.svelte126
-rw-r--r--packages/ui/src/components/ModLoaderSelector.svelte455
-rw-r--r--packages/ui/src/components/ParticleBackground.svelte70
-rw-r--r--packages/ui/src/components/SettingsView.svelte1217
-rw-r--r--packages/ui/src/components/Sidebar.svelte91
-rw-r--r--packages/ui/src/components/StatusToast.svelte42
-rw-r--r--packages/ui/src/components/VersionsView.svelte511
-rw-r--r--packages/ui/src/lib/Counter.svelte10
-rw-r--r--packages/ui/src/lib/DownloadMonitor.svelte201
-rw-r--r--packages/ui/src/lib/GameConsole.svelte304
-rw-r--r--packages/ui/src/lib/effects/ConstellationEffect.ts162
-rw-r--r--packages/ui/src/lib/effects/SaturnEffect.ts340
-rw-r--r--packages/ui/src/lib/modLoaderApi.ts106
-rw-r--r--packages/ui/src/main.ts9
-rw-r--r--packages/ui/src/stores/assistant.svelte.ts166
-rw-r--r--packages/ui/src/stores/auth.svelte.ts192
-rw-r--r--packages/ui/src/stores/game.svelte.ts78
-rw-r--r--packages/ui/src/stores/instances.svelte.ts109
-rw-r--r--packages/ui/src/stores/logs.svelte.ts151
-rw-r--r--packages/ui/src/stores/releases.svelte.ts36
-rw-r--r--packages/ui/src/stores/settings.svelte.ts570
-rw-r--r--packages/ui/src/stores/ui.svelte.ts32
-rw-r--r--packages/ui/src/types/index.ts232
34 files changed, 8277 insertions, 0 deletions
diff --git a/packages/ui/src/App.svelte b/packages/ui/src/App.svelte
new file mode 100644
index 0000000..f73e0a2
--- /dev/null
+++ b/packages/ui/src/App.svelte
@@ -0,0 +1,217 @@
+<script lang="ts">
+ import { getVersion } from "@tauri-apps/api/app";
+ // import { convertFileSrc } from "@tauri-apps/api/core"; // Removed duplicate, handled by import below or inline
+ import { onDestroy, onMount } from "svelte";
+ import DownloadMonitor from "./lib/DownloadMonitor.svelte";
+ import GameConsole from "./lib/GameConsole.svelte";
+// Components
+ import BottomBar from "./components/BottomBar.svelte";
+ import HomeView from "./components/HomeView.svelte";
+ import LoginModal from "./components/LoginModal.svelte";
+ import ParticleBackground from "./components/ParticleBackground.svelte";
+ import SettingsView from "./components/SettingsView.svelte";
+ import AssistantView from "./components/AssistantView.svelte";
+ import InstancesView from "./components/InstancesView.svelte";
+ import Sidebar from "./components/Sidebar.svelte";
+ import StatusToast from "./components/StatusToast.svelte";
+ import VersionsView from "./components/VersionsView.svelte";
+// Stores
+ import { authState } from "./stores/auth.svelte";
+ import { gameState } from "./stores/game.svelte";
+ import { instancesState } from "./stores/instances.svelte";
+ import { settingsState } from "./stores/settings.svelte";
+ import { uiState } from "./stores/ui.svelte";
+ import { logsState } from "./stores/logs.svelte";
+ import { convertFileSrc } from "@tauri-apps/api/core";
+
+ 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 () => {
+ // ENFORCE DARK MODE: Always add 'dark' class and attribute
+ document.documentElement.classList.add('dark');
+ document.documentElement.setAttribute('data-theme', 'dark');
+ document.documentElement.classList.remove('light');
+
+ authState.checkAccount();
+ await settingsState.loadSettings();
+ logsState.init();
+ await settingsState.detectJava();
+ await instancesState.loadInstances();
+ gameState.loadVersions();
+ getVersion().then((v) => (uiState.appVersion = v));
+ window.addEventListener("mousemove", handleMouseMove);
+ });
+
+ // Refresh versions when active instance changes
+ $effect(() => {
+ if (instancesState.activeInstanceId) {
+ gameState.loadVersions();
+ } else {
+ gameState.versions = [];
+ }
+ });
+
+ onDestroy(() => {
+ if (typeof window !== 'undefined')
+ window.removeEventListener("mousemove", handleMouseMove);
+ });
+</script>
+
+<div
+ class="relative h-screen w-screen overflow-hidden dark:text-white text-gray-900 font-sans selection:bg-indigo-500/30"
+>
+ <!-- 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"
+ onerror={(e) => console.error("Failed to load main background:", e)}
+ />
+ <!-- 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}
+
+ {#if uiState.currentView === "home"}
+ <ParticleBackground />
+ {/if}
+
+ <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>
+ </div>
+
+ <!-- Content Wrapper -->
+ <div class="relative z-10 flex h-full p-4 gap-4 text-gray-900 dark:text-white">
+ <!-- Floating Sidebar -->
+ <Sidebar />
+
+ <!-- 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="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 === "instances"}
+ <InstancesView />
+ {:else if uiState.currentView === "versions"}
+ <VersionsView />
+ {:else if uiState.currentView === "settings"}
+ <SettingsView />
+ {:else if uiState.currentView === "guide"}
+ <AssistantView />
+ {/if}
+ </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>
+
+ <!-- Bottom Bar -->
+ {#if uiState.currentView === "home"}
+ <BottomBar />
+ {/if}
+ </div>
+ </main>
+ </div>
+
+ <LoginModal />
+ <StatusToast />
+
+ <!-- Logout Confirmation Dialog -->
+ {#if authState.isLogoutConfirmOpen}
+ <div class="fixed inset-0 z-[200] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
+ <div class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200">
+ <h3 class="text-lg font-bold text-white mb-2">Logout</h3>
+ <p class="text-zinc-400 text-sm mb-6">
+ Are you sure you want to logout <span class="text-white font-medium">{authState.currentAccount?.username}</span>?
+ </p>
+ <div class="flex gap-3 justify-end">
+ <button
+ onclick={() => authState.cancelLogout()}
+ class="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={() => authState.confirmLogout()}
+ class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors"
+ >
+ Logout
+ </button>
+ </div>
+ </div>
+ </div>
+ {/if}
+
+ {#if uiState.showConsole}
+ <div class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-8">
+ <div class="w-full h-full max-w-6xl max-h-[85vh] bg-[#1e1e1e] rounded-lg overflow-hidden border border-zinc-700 shadow-2xl relative flex flex-col">
+ <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/packages/ui/src/app.css b/packages/ui/src/app.css
new file mode 100644
index 0000000..63449b7
--- /dev/null
+++ b/packages/ui/src/app.css
@@ -0,0 +1,167 @@
+@import "tailwindcss";
+
+@variant dark (&:where(.dark, .dark *));
+
+/* ==================== Custom Select/Dropdown Styles ==================== */
+
+/* Base select styling */
+select {
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2371717a'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.5rem center;
+ background-size: 1rem;
+ padding-right: 2rem;
+}
+
+/* Option styling - works in WebView/Chromium */
+select option {
+ background-color: #18181b;
+ color: #e4e4e7;
+ padding: 12px 16px;
+ font-size: 13px;
+ border: none;
+}
+
+select option:hover,
+select option:focus {
+ background-color: #3730a3 !important;
+ color: white !important;
+}
+
+select option:checked {
+ background: linear-gradient(0deg, #4f46e5 0%, #4f46e5 100%);
+ color: white;
+ font-weight: 500;
+}
+
+select option:disabled {
+ color: #52525b;
+ background-color: #18181b;
+}
+
+/* Optgroup styling */
+select optgroup {
+ background-color: #18181b;
+ color: #a1a1aa;
+ font-weight: 600;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 8px 12px 4px;
+}
+
+/* Select focus state */
+select:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
+}
+
+/* ==================== Custom Scrollbar (Global) ==================== */
+
+/* Firefox */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: #3f3f46 transparent;
+}
+
+/* Webkit browsers */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background-color: #3f3f46;
+ border-radius: 4px;
+ border: 2px solid transparent;
+ background-clip: content-box;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background-color: #52525b;
+}
+
+::-webkit-scrollbar-corner {
+ background: transparent;
+}
+
+/* ==================== Input/Form Element Consistency ==================== */
+
+input[type="text"],
+input[type="number"],
+input[type="password"],
+input[type="email"],
+textarea {
+ background-color: rgba(0, 0, 0, 0.4);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+input[type="text"]:focus,
+input[type="number"]:focus,
+input[type="password"]:focus,
+input[type="email"]:focus,
+textarea:focus {
+ border-color: rgba(99, 102, 241, 0.5);
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
+ outline: none;
+}
+
+/* Number input - hide spinner */
+input[type="number"]::-webkit-outer-spin-button,
+input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+input[type="number"] {
+ -moz-appearance: textfield;
+}
+
+/* ==================== Checkbox Styling ==================== */
+
+input[type="checkbox"] {
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ background-color: #18181b;
+ cursor: pointer;
+ position: relative;
+ transition: all 0.15s ease;
+}
+
+input[type="checkbox"]:hover {
+ border-color: #52525b;
+}
+
+input[type="checkbox"]:checked {
+ background-color: #4f46e5;
+ border-color: #4f46e5;
+}
+
+input[type="checkbox"]:checked::after {
+ content: "";
+ position: absolute;
+ left: 5px;
+ top: 2px;
+ width: 4px;
+ height: 8px;
+ border: solid white;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+}
+
+input[type="checkbox"]:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
+}
diff --git a/packages/ui/src/assets/svelte.svg b/packages/ui/src/assets/svelte.svg
new file mode 100644
index 0000000..8c056ce
--- /dev/null
+++ b/packages/ui/src/assets/svelte.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
diff --git a/packages/ui/src/components/AssistantView.svelte b/packages/ui/src/components/AssistantView.svelte
new file mode 100644
index 0000000..54509a5
--- /dev/null
+++ b/packages/ui/src/components/AssistantView.svelte
@@ -0,0 +1,436 @@
+<script lang="ts">
+ import { assistantState } from '../stores/assistant.svelte';
+ import { settingsState } from '../stores/settings.svelte';
+ import { Send, Bot, RefreshCw, Trash2, AlertTriangle, Settings, Brain, ChevronDown } from 'lucide-svelte';
+ import { uiState } from '../stores/ui.svelte';
+ import { marked } from 'marked';
+ import { onMount } from 'svelte';
+
+ let input = $state('');
+ let messagesContainer: HTMLDivElement | undefined = undefined;
+
+ function parseMessageContent(content: string) {
+ if (!content) return { thinking: null, content: '', isThinking: false };
+
+ // Support both <thinking> and <think> (DeepSeek uses <think>)
+ let startTag = '<thinking>';
+ let endTag = '</thinking>';
+ let startIndex = content.indexOf(startTag);
+
+ if (startIndex === -1) {
+ startTag = '<think>';
+ endTag = '</think>';
+ startIndex = content.indexOf(startTag);
+ }
+
+ // Also check for encoded tags if they weren't decoded properly
+ if (startIndex === -1) {
+ startTag = '\u003cthink\u003e';
+ endTag = '\u003c/think\u003e';
+ startIndex = content.indexOf(startTag);
+ }
+
+ if (startIndex !== -1) {
+ const endIndex = content.indexOf(endTag, startIndex);
+
+ if (endIndex !== -1) {
+ // Completed thinking block
+ // We extract the thinking part and keep the rest (before and after)
+ const before = content.substring(0, startIndex);
+ const thinking = content.substring(startIndex + startTag.length, endIndex).trim();
+ const after = content.substring(endIndex + endTag.length);
+
+ return {
+ thinking,
+ content: (before + after).trim(),
+ isThinking: false
+ };
+ } else {
+ // Incomplete thinking block (still streaming)
+ const before = content.substring(0, startIndex);
+ const thinking = content.substring(startIndex + startTag.length).trim();
+
+ return {
+ thinking,
+ content: before.trim(),
+ isThinking: true
+ };
+ }
+ }
+
+ return { thinking: null, content, isThinking: false };
+ }
+
+ function renderMarkdown(content: string): string {
+ if (!content) return '';
+ try {
+ // marked.parse returns string synchronously when async is false (default)
+ return marked(content, { breaks: true, gfm: true }) as string;
+ } catch {
+ return content;
+ }
+ }
+
+ function scrollToBottom() {
+ if (messagesContainer) {
+ setTimeout(() => {
+ if (messagesContainer) {
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
+ }
+ }, 0);
+ }
+ }
+
+ onMount(() => {
+ assistantState.init();
+ });
+
+ // Scroll to bottom when messages change
+ $effect(() => {
+ // Access reactive state
+ const _len = assistantState.messages.length;
+ const _processing = assistantState.isProcessing;
+ // Scroll on next tick
+ if (_len > 0 || _processing) {
+ scrollToBottom();
+ }
+ });
+
+ async function handleSubmit() {
+ if (!input.trim() || assistantState.isProcessing) return;
+ const text = input;
+ input = '';
+ const provider = settingsState.settings.assistant.llm_provider;
+ const endpoint = provider === 'ollama'
+ ? settingsState.settings.assistant.ollama_endpoint
+ : settingsState.settings.assistant.openai_endpoint;
+ await assistantState.sendMessage(
+ text,
+ settingsState.settings.assistant.enabled,
+ provider,
+ endpoint
+ );
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ }
+
+ function getProviderName(): string {
+ const provider = settingsState.settings.assistant.llm_provider;
+ if (provider === 'ollama') {
+ return `Ollama (${settingsState.settings.assistant.ollama_model})`;
+ } else if (provider === 'openai') {
+ return `OpenAI (${settingsState.settings.assistant.openai_model})`;
+ }
+ return provider;
+ }
+
+ function getProviderHelpText(): string {
+ const provider = settingsState.settings.assistant.llm_provider;
+ if (provider === 'ollama') {
+ return `Please ensure Ollama is installed and running at ${settingsState.settings.assistant.ollama_endpoint}.`;
+ } else if (provider === 'openai') {
+ return "Please check your OpenAI API key in Settings > AI Assistant.";
+ }
+ return "";
+ }
+</script>
+
+<div class="h-full w-full flex flex-col gap-4 p-4 lg:p-8 animate-in fade-in zoom-in-95 duration-300">
+ <div class="flex items-center justify-between mb-2">
+ <div class="flex items-center gap-3">
+ <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
+ <Bot size={24} />
+ </div>
+ <div>
+ <h2 class="text-2xl font-bold">Game Assistant</h2>
+ <p class="text-zinc-400 text-sm">Powered by {getProviderName()}</p>
+ </div>
+ </div>
+
+ <div class="flex items-center gap-2">
+ {#if !settingsState.settings.assistant.enabled}
+ <div class="flex items-center gap-2 px-3 py-1.5 bg-zinc-500/10 text-zinc-400 rounded-full text-xs font-medium border border-zinc-500/20">
+ <AlertTriangle size={14} />
+ <span>Disabled</span>
+ </div>
+ {:else if !assistantState.isProviderHealthy}
+ <div class="flex items-center gap-2 px-3 py-1.5 bg-red-500/10 text-red-400 rounded-full text-xs font-medium border border-red-500/20">
+ <AlertTriangle size={14} />
+ <span>Offline</span>
+ </div>
+ {:else}
+ <div class="flex items-center gap-2 px-3 py-1.5 bg-emerald-500/10 text-emerald-400 rounded-full text-xs font-medium border border-emerald-500/20">
+ <div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
+ <span>Online</span>
+ </div>
+ {/if}
+
+ <button
+ onclick={() => assistantState.checkHealth()}
+ class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
+ title="Check Connection"
+ >
+ <RefreshCw size={18} class={assistantState.isProcessing ? "animate-spin" : ""} />
+ </button>
+
+ <button
+ onclick={() => assistantState.clearHistory()}
+ class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
+ title="Clear History"
+ >
+ <Trash2 size={18} />
+ </button>
+
+ <button
+ onclick={() => uiState.setView('settings')}
+ class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
+ title="Settings"
+ >
+ <Settings size={18} />
+ </button>
+ </div>
+ </div>
+
+ <!-- Chat Area -->
+ <div class="flex-1 bg-black/20 border border-white/5 rounded-xl overflow-hidden flex flex-col relative">
+ {#if assistantState.messages.length === 0}
+ <div class="absolute inset-0 flex flex-col items-center justify-center text-zinc-500 gap-4 p-8 text-center">
+ <Bot size={48} class="opacity-20" />
+ <div class="max-w-md">
+ <p class="text-lg font-medium text-zinc-300 mb-2">How can I help you today?</p>
+ <p class="text-sm opacity-70">I can analyze your game logs, diagnose crashes, or explain mod features.</p>
+ </div>
+ {#if !settingsState.settings.assistant.enabled}
+ <div class="bg-zinc-500/10 border border-zinc-500/20 rounded-lg p-4 text-sm text-zinc-400 mt-4 max-w-sm">
+ Assistant is disabled. Enable it in <button onclick={() => uiState.setView('settings')} class="text-indigo-400 hover:underline">Settings > AI Assistant</button>.
+ </div>
+ {:else if !assistantState.isProviderHealthy}
+ <div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-sm text-red-400 mt-4 max-w-sm">
+ {getProviderHelpText()}
+ </div>
+ {/if}
+ </div>
+ {/if}
+
+ <div
+ bind:this={messagesContainer}
+ class="flex-1 overflow-y-auto p-4 space-y-4 scroll-smooth"
+ >
+ {#each assistantState.messages as msg, idx}
+ <div class="flex gap-3 {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
+ {#if msg.role === 'assistant'}
+ <div class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-400 shrink-0 mt-1">
+ <Bot size={16} />
+ </div>
+ {/if}
+
+ <div class="max-w-[80%] p-3 rounded-2xl {msg.role === 'user' ? 'bg-indigo-600 text-white rounded-tr-none' : 'bg-zinc-800/50 text-zinc-200 rounded-tl-none border border-white/5'}">
+ {#if msg.role === 'user'}
+ <div class="break-words whitespace-pre-wrap">
+ {msg.content}
+ </div>
+ {:else}
+ {@const parsed = parseMessageContent(msg.content)}
+
+ <!-- Thinking Block -->
+ {#if parsed.thinking}
+ <div class="mb-3 max-w-full overflow-hidden">
+ <details class="group" open={parsed.isThinking}>
+ <summary class="list-none cursor-pointer flex items-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors text-xs font-medium select-none bg-black/20 p-2 rounded-lg border border-white/5 w-fit mb-2 outline-none">
+ <Brain size={14} />
+ <span>Thinking Process</span>
+ <ChevronDown size={14} class="transition-transform duration-200 group-open:rotate-180" />
+ </summary>
+ <div class="pl-3 border-l-2 border-zinc-700 text-zinc-500 text-xs italic leading-relaxed whitespace-pre-wrap font-mono max-h-96 overflow-y-auto custom-scrollbar bg-black/10 p-2 rounded-r-md">
+ {parsed.thinking}
+ {#if parsed.isThinking}
+ <span class="inline-block w-1.5 h-3 bg-zinc-500 ml-1 animate-pulse align-middle"></span>
+ {/if}
+ </div>
+ </details>
+ </div>
+ {/if}
+
+ <!-- Markdown rendered content for assistant -->
+ <div class="markdown-content prose prose-invert prose-sm max-w-none">
+ {#if parsed.content}
+ {@html renderMarkdown(parsed.content)}
+ {:else if assistantState.isProcessing && idx === assistantState.messages.length - 1 && !parsed.isThinking}
+ <span class="inline-flex items-center gap-1">
+ <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse"></span>
+ <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse" style="animation-delay: 0.2s"></span>
+ <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse" style="animation-delay: 0.4s"></span>
+ </span>
+ {/if}
+ </div>
+
+ <!-- Generation Stats -->
+ {#if msg.stats}
+ <div class="mt-3 pt-3 border-t border-white/5 text-[10px] text-zinc-500 font-mono flex flex-wrap gap-x-4 gap-y-1 opacity-70 hover:opacity-100 transition-opacity select-none">
+ <div class="flex gap-1" title="Tokens generated">
+ <span>Eval:</span>
+ <span class="text-zinc-400">{msg.stats.eval_count} tokens</span>
+ </div>
+ <div class="flex gap-1" title="Total duration">
+ <span>Time:</span>
+ <span class="text-zinc-400">{(msg.stats.total_duration / 1e9).toFixed(2)}s</span>
+ </div>
+ {#if msg.stats.eval_duration > 0}
+ <div class="flex gap-1" title="Generation speed">
+ <span>Speed:</span>
+ <span class="text-zinc-400">{(msg.stats.eval_count / (msg.stats.eval_duration / 1e9)).toFixed(1)} t/s</span>
+ </div>
+ {/if}
+ </div>
+ {/if}
+ {/if}
+ </div>
+ </div>
+ {/each}
+ </div>
+
+ <!-- Input Area -->
+ <div class="p-4 bg-zinc-900/50 border-t border-white/5">
+ <div class="relative">
+ <textarea
+ bind:value={input}
+ onkeydown={handleKeydown}
+ placeholder={settingsState.settings.assistant.enabled ? "Ask about your game..." : "Assistant is disabled..."}
+ class="w-full bg-black/20 border border-white/10 rounded-xl py-3 pl-4 pr-12 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 resize-none h-[52px] max-h-32 transition-all text-white disabled:opacity-50"
+ disabled={assistantState.isProcessing || !settingsState.settings.assistant.enabled}
+ ></textarea>
+
+ <button
+ onclick={handleSubmit}
+ disabled={!input.trim() || assistantState.isProcessing || !settingsState.settings.assistant.enabled}
+ class="absolute right-2 top-2 p-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:hover:bg-indigo-600 text-white rounded-lg transition-colors"
+ >
+ <Send size={16} />
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<style>
+ /* Markdown content styles */
+ .markdown-content :global(p) {
+ margin-bottom: 0.5rem;
+ }
+
+ .markdown-content :global(p:last-child) {
+ margin-bottom: 0;
+ }
+
+ .markdown-content :global(pre) {
+ background-color: rgba(0, 0, 0, 0.4);
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ overflow-x: auto;
+ margin: 0.5rem 0;
+ }
+
+ .markdown-content :global(code) {
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
+ font-size: 0.85em;
+ }
+
+ .markdown-content :global(pre code) {
+ background: none;
+ padding: 0;
+ }
+
+ .markdown-content :global(:not(pre) > code) {
+ background-color: rgba(0, 0, 0, 0.3);
+ padding: 0.15rem 0.4rem;
+ border-radius: 0.25rem;
+ }
+
+ .markdown-content :global(ul),
+ .markdown-content :global(ol) {
+ margin: 0.5rem 0;
+ padding-left: 1.5rem;
+ }
+
+ .markdown-content :global(li) {
+ margin: 0.25rem 0;
+ }
+
+ .markdown-content :global(blockquote) {
+ border-left: 3px solid rgba(99, 102, 241, 0.5);
+ padding-left: 1rem;
+ margin: 0.5rem 0;
+ color: rgba(255, 255, 255, 0.7);
+ }
+
+ .markdown-content :global(h1),
+ .markdown-content :global(h2),
+ .markdown-content :global(h3),
+ .markdown-content :global(h4) {
+ font-weight: 600;
+ margin: 0.75rem 0 0.5rem 0;
+ }
+
+ .markdown-content :global(h1) {
+ font-size: 1.25rem;
+ }
+
+ .markdown-content :global(h2) {
+ font-size: 1.125rem;
+ }
+
+ .markdown-content :global(h3) {
+ font-size: 1rem;
+ }
+
+ .markdown-content :global(a) {
+ color: rgb(129, 140, 248);
+ text-decoration: underline;
+ }
+
+ .markdown-content :global(a:hover) {
+ color: rgb(165, 180, 252);
+ }
+
+ .markdown-content :global(table) {
+ border-collapse: collapse;
+ margin: 0.5rem 0;
+ width: 100%;
+ }
+
+ .markdown-content :global(th),
+ .markdown-content :global(td) {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ padding: 0.5rem;
+ text-align: left;
+ }
+
+ .markdown-content :global(th) {
+ background-color: rgba(0, 0, 0, 0.3);
+ font-weight: 600;
+ }
+
+ .markdown-content :global(hr) {
+ border: none;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ margin: 1rem 0;
+ }
+
+ .markdown-content :global(img) {
+ max-width: 100%;
+ border-radius: 0.5rem;
+ }
+
+ .markdown-content :global(strong) {
+ font-weight: 600;
+ }
+
+ .markdown-content :global(em) {
+ font-style: italic;
+ }
+</style>
diff --git a/packages/ui/src/components/BottomBar.svelte b/packages/ui/src/components/BottomBar.svelte
new file mode 100644
index 0000000..19cf35d
--- /dev/null
+++ b/packages/ui/src/components/BottomBar.svelte
@@ -0,0 +1,250 @@
+<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
+ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+ import { authState } from "../stores/auth.svelte";
+ import { gameState } from "../stores/game.svelte";
+ import { uiState } from "../stores/ui.svelte";
+ import { instancesState } from "../stores/instances.svelte";
+ import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte';
+
+ interface InstalledVersion {
+ id: string;
+ type: string;
+ }
+
+ let isVersionDropdownOpen = $state(false);
+ let dropdownRef: HTMLDivElement;
+ let installedVersions = $state<InstalledVersion[]>([]);
+ let isLoadingVersions = $state(true);
+ let downloadCompleteUnlisten: UnlistenFn | null = null;
+ let versionDeletedUnlisten: UnlistenFn | null = null;
+
+ // Load installed versions on mount
+ $effect(() => {
+ loadInstalledVersions();
+ setupEventListeners();
+ return () => {
+ if (downloadCompleteUnlisten) {
+ downloadCompleteUnlisten();
+ }
+ if (versionDeletedUnlisten) {
+ versionDeletedUnlisten();
+ }
+ };
+ });
+
+ async function setupEventListeners() {
+ // Refresh list when a download completes
+ downloadCompleteUnlisten = await listen("download-complete", () => {
+ loadInstalledVersions();
+ });
+ // Refresh list when a version is deleted
+ versionDeletedUnlisten = await listen("version-deleted", () => {
+ loadInstalledVersions();
+ });
+ }
+
+ async function loadInstalledVersions() {
+ if (!instancesState.activeInstanceId) {
+ installedVersions = [];
+ isLoadingVersions = false;
+ return;
+ }
+ isLoadingVersions = true;
+ try {
+ installedVersions = await invoke<InstalledVersion[]>("list_installed_versions", {
+ instanceId: instancesState.activeInstanceId,
+ });
+ // If no version is selected but we have installed versions, select the first one
+ if (!gameState.selectedVersion && installedVersions.length > 0) {
+ gameState.selectedVersion = installedVersions[0].id;
+ }
+ } catch (e) {
+ console.error("Failed to load installed versions:", e);
+ } finally {
+ isLoadingVersions = false;
+ }
+ }
+
+ let versionOptions = $derived(
+ isLoadingVersions
+ ? [{ id: "loading", type: "loading", label: "Loading..." }]
+ : installedVersions.length === 0
+ ? [{ id: "empty", type: "empty", label: "No versions installed" }]
+ : installedVersions.map(v => ({
+ ...v,
+ label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}`
+ }))
+ );
+
+ function selectVersion(id: string) {
+ if (id !== "loading" && id !== "empty") {
+ gameState.selectedVersion = id;
+ isVersionDropdownOpen = false;
+ }
+ }
+
+ function handleClickOutside(e: MouseEvent) {
+ if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
+ isVersionDropdownOpen = false;
+ }
+ }
+
+ $effect(() => {
+ if (isVersionDropdownOpen) {
+ document.addEventListener('click', handleClickOutside);
+ return () => document.removeEventListener('click', handleClickOutside);
+ }
+ });
+
+ function getVersionTypeColor(type: string) {
+ switch (type) {
+ case 'fabric': return 'text-indigo-400';
+ case 'forge': return 'text-orange-400';
+ case 'snapshot': return 'text-amber-400';
+ case 'modpack': return 'text-purple-400';
+ default: return 'text-emerald-400';
+ }
+ }
+</script>
+
+<div
+ class="h-20 bg-white/80 dark:bg-[#09090b]/90 border-t dark:border-white/10 border-black/5 flex items-center px-8 justify-between z-20 backdrop-blur-md"
+>
+ <!-- Account Area -->
+ <div class="flex items-center gap-6">
+ <div
+ class="group flex items-center gap-4 cursor-pointer"
+ onclick={() => authState.openLoginModal()}
+ role="button"
+ tabindex="0"
+ onkeydown={(e) => e.key === "Enter" && authState.openLoginModal()}
+ >
+ <div
+ class="w-10 h-10 rounded-sm bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 flex items-center justify-center overflow-hidden transition-all group-hover:border-zinc-400 dark:group-hover:border-zinc-500"
+ >
+ {#if authState.currentAccount}
+ <img
+ src={`https://minotar.net/avatar/${authState.currentAccount.username}/48`}
+ alt={authState.currentAccount.username}
+ class="w-full h-full"
+ />
+ {:else}
+ <User size={20} class="text-zinc-400" />
+ {/if}
+ </div>
+ <div>
+ <div class="font-bold dark:text-white text-gray-900 text-sm group-hover:text-black dark:group-hover:text-zinc-200 transition-colors">
+ {authState.currentAccount ? authState.currentAccount.username : "Login Account"}
+ </div>
+ <div class="text-[10px] uppercase tracking-wider dark:text-zinc-500 text-gray-500 flex items-center gap-2">
+ {#if authState.currentAccount}
+ {#if authState.currentAccount.type === "Microsoft"}
+ {#if authState.currentAccount.expires_at && authState.currentAccount.expires_at * 1000 < Date.now()}
+ <span class="w-1.5 h-1.5 rounded-full bg-red-500"></span>
+ <span class="text-red-400">Expired</span>
+ {:else}
+ <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
+ Online
+ {/if}
+ {:else}
+ <span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span>
+ Offline
+ {/if}
+ {:else}
+ <span class="w-1.5 h-1.5 rounded-full bg-zinc-400"></span>
+ Guest
+ {/if}
+ </div>
+ </div>
+ </div>
+
+ <div class="h-8 w-px dark:bg-white/10 bg-black/10"></div>
+
+ <!-- Console Toggle -->
+ <button
+ 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 px-2 py-1 rounded-sm hover:bg-black/5 dark:hover:bg-white/5"
+ onclick={() => uiState.toggleConsole()}
+ >
+ <Terminal size={14} />
+ {uiState.showConsole ? "HIDE LOGS" : "SHOW LOGS"}
+ </button>
+ </div>
+
+ <!-- Action Area -->
+ <div class="flex items-center gap-4">
+ <div class="flex flex-col items-end mr-2">
+ <!-- Custom Version Dropdown -->
+ <div class="relative" bind:this={dropdownRef}>
+ <button
+ type="button"
+ onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen}
+ disabled={installedVersions.length === 0 && !isLoadingVersions}
+ class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left
+ dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md
+ text-sm font-mono dark:text-white text-gray-900
+ dark:hover:border-zinc-600 hover:border-zinc-400
+ focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+ transition-colors cursor-pointer outline-none
+ disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ <span class="truncate">
+ {#if isLoadingVersions}
+ Loading...
+ {:else if installedVersions.length === 0}
+ No versions installed
+ {:else}
+ {gameState.selectedVersion || "Select version"}
+ {/if}
+ </span>
+ <ChevronDown
+ size={14}
+ class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}"
+ />
+ </button>
+
+ {#if isVersionDropdownOpen && installedVersions.length > 0}
+ <div
+ class="absolute z-50 w-full mt-1 py-1 dark:bg-zinc-900 bg-white border dark:border-zinc-700 border-zinc-300 rounded-md shadow-xl
+ max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1 right-0"
+ >
+ {#each versionOptions as version}
+ <button
+ type="button"
+ onclick={() => selectVersion(version.id)}
+ disabled={version.id === "loading" || version.id === "empty"}
+ class="w-full flex items-center justify-between px-3 py-2 text-sm font-mono text-left
+ transition-colors outline-none
+ {version.id === gameState.selectedVersion
+ ? 'bg-indigo-600 text-white'
+ : 'dark:text-zinc-300 text-gray-700 dark:hover:bg-zinc-800 hover:bg-zinc-100'}
+ {version.id === 'loading' || version.id === 'empty' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
+ >
+ <span class="truncate flex items-center gap-2">
+ {version.id}
+ {#if version.type && version.type !== 'release' && version.type !== 'loading' && version.type !== 'empty'}
+ <span class="text-[10px] uppercase {version.id === gameState.selectedVersion ? 'text-white/70' : getVersionTypeColor(version.type)}">
+ {version.type}
+ </span>
+ {/if}
+ </span>
+ {#if version.id === gameState.selectedVersion}
+ <Check size={14} class="shrink-0 ml-2" />
+ {/if}
+ </button>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <button
+ onclick={() => gameState.startGame()}
+ disabled={installedVersions.length === 0 || !gameState.selectedVersion}
+ class="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase"
+ >
+ <Play size={24} fill="currentColor" />
+ <span>Launch</span>
+ </button>
+ </div>
+</div>
diff --git a/packages/ui/src/components/ConfigEditorModal.svelte b/packages/ui/src/components/ConfigEditorModal.svelte
new file mode 100644
index 0000000..dd866ee
--- /dev/null
+++ b/packages/ui/src/components/ConfigEditorModal.svelte
@@ -0,0 +1,369 @@
+<script lang="ts">
+ import { settingsState } from "../stores/settings.svelte";
+ import { Save, X, FileJson, AlertCircle, Undo, Redo, Settings } from "lucide-svelte";
+ import Prism from 'prismjs';
+ import 'prismjs/components/prism-json';
+ import 'prismjs/themes/prism-tomorrow.css';
+
+ let content = $state(settingsState.rawConfigContent);
+ let isSaving = $state(false);
+ let localError = $state("");
+
+ let textareaRef: HTMLTextAreaElement | undefined = $state();
+ let preRef: HTMLPreElement | undefined = $state();
+ let lineNumbersRef: HTMLDivElement | undefined = $state();
+
+ // Textarea attributes that TypeScript doesn't recognize but are valid HTML
+ const textareaAttrs = {
+ autocorrect: "off",
+ autocapitalize: "off"
+ } as Record<string, string>;
+
+ // History State
+ let history = $state([settingsState.rawConfigContent]);
+ let historyIndex = $state(0);
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
+
+ // Editor Settings
+ let showLineNumbers = $state(localStorage.getItem('editor_showLineNumbers') !== 'false');
+ let showStatusBar = $state(localStorage.getItem('editor_showStatusBar') !== 'false');
+ let showSettings = $state(false);
+
+ // Cursor Status
+ let cursorLine = $state(1);
+ let cursorCol = $state(1);
+
+ let lines = $derived(content.split('\n'));
+
+ $effect(() => {
+ localStorage.setItem('editor_showLineNumbers', String(showLineNumbers));
+ localStorage.setItem('editor_showStatusBar', String(showStatusBar));
+ });
+
+ // Cleanup timer on destroy
+ $effect(() => {
+ return () => {
+ if (debounceTimer) clearTimeout(debounceTimer);
+ };
+ });
+
+ // Initial validation
+ $effect(() => {
+ validate(content);
+ });
+
+ function validate(text: string) {
+ try {
+ JSON.parse(text);
+ localError = "";
+ } catch (e: any) {
+ localError = e.message;
+ }
+ }
+
+ function pushHistory(newContent: string, immediate = false) {
+ if (debounceTimer) clearTimeout(debounceTimer);
+
+ const commit = () => {
+ if (newContent === history[historyIndex]) return;
+ const next = history.slice(0, historyIndex + 1);
+ next.push(newContent);
+ history = next;
+ historyIndex = next.length - 1;
+ };
+
+ if (immediate) {
+ commit();
+ } else {
+ debounceTimer = setTimeout(commit, 500);
+ }
+ }
+
+ function handleUndo() {
+ if (historyIndex > 0) {
+ historyIndex--;
+ content = history[historyIndex];
+ validate(content);
+ }
+ }
+
+ function handleRedo() {
+ if (historyIndex < history.length - 1) {
+ historyIndex++;
+ content = history[historyIndex];
+ validate(content);
+ }
+ }
+
+ function updateCursor() {
+ if (!textareaRef) return;
+ const pos = textareaRef.selectionStart;
+ const text = textareaRef.value.substring(0, pos);
+ const lines = text.split('\n');
+ cursorLine = lines.length;
+ cursorCol = lines[lines.length - 1].length + 1;
+ }
+
+ function handleInput(e: Event) {
+ const target = e.target as HTMLTextAreaElement;
+ content = target.value;
+ validate(content);
+ pushHistory(content);
+ updateCursor();
+ }
+
+ function handleScroll() {
+ if (textareaRef) {
+ if (preRef) {
+ preRef.scrollTop = textareaRef.scrollTop;
+ preRef.scrollLeft = textareaRef.scrollLeft;
+ }
+ if (lineNumbersRef) {
+ lineNumbersRef.scrollTop = textareaRef.scrollTop;
+ }
+ }
+ }
+
+ let highlightedCode = $derived(
+ Prism.highlight(content, Prism.languages.json, 'json') + '\n'
+ );
+
+ async function handleSave(close = false) {
+ if (localError) return;
+ isSaving = true;
+ await settingsState.saveRawConfig(content, close);
+ isSaving = false;
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ // Save
+ if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ handleSave(false); // Keep open on shortcut save
+ }
+ // Undo
+ else if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
+ e.preventDefault();
+ handleUndo();
+ }
+ // Redo (Ctrl+Shift+Z or Ctrl+Y)
+ else if (
+ (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey) ||
+ (e.key === 'y' && (e.ctrlKey || e.metaKey))
+ ) {
+ e.preventDefault();
+ handleRedo();
+ }
+ // Close
+ else if (e.key === 'Escape') {
+ settingsState.closeConfigEditor();
+ }
+ // Tab
+ else if (e.key === 'Tab') {
+ e.preventDefault();
+ const target = e.target as HTMLTextAreaElement;
+ const start = target.selectionStart;
+ const end = target.selectionEnd;
+
+ pushHistory(content, true);
+
+ const newContent = content.substring(0, start) + " " + content.substring(end);
+ content = newContent;
+
+ pushHistory(content, true);
+
+ setTimeout(() => {
+ target.selectionStart = target.selectionEnd = start + 2;
+ updateCursor();
+ }, 0);
+ validate(content);
+ }
+ }
+</script>
+
+<div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70 animate-in fade-in duration-200">
+ <div
+ class="bg-[#1d1f21] rounded-xl border border-zinc-700 shadow-2xl w-[900px] max-w-[95vw] h-[85vh] flex flex-col overflow-hidden"
+ role="dialog"
+ aria-modal="true"
+ >
+ <!-- Header -->
+ <div class="flex items-center justify-between p-4 border-b border-zinc-700 bg-[#1d1f21] z-20 relative">
+ <div class="flex items-center gap-3">
+ <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
+ <FileJson size={20} />
+ </div>
+ <div class="flex flex-col">
+ <h3 class="text-lg font-bold text-white leading-none">Configuration Editor</h3>
+ <span class="text-[10px] text-zinc-500 font-mono mt-1 break-all">{settingsState.configFilePath}</span>
+ </div>
+ </div>
+ <div class="flex items-center gap-2">
+ <!-- Undo/Redo Buttons -->
+ <div class="flex items-center bg-zinc-800 rounded-lg p-0.5 mr-2 border border-zinc-700">
+ <button
+ onclick={handleUndo}
+ disabled={historyIndex === 0}
+ class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
+ title="Undo (Ctrl+Z)"
+ >
+ <Undo size={16} />
+ </button>
+ <button
+ onclick={handleRedo}
+ disabled={historyIndex === history.length - 1}
+ class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
+ title="Redo (Ctrl+Y)"
+ >
+ <Redo size={16} />
+ </button>
+ </div>
+
+ <!-- Settings Toggle -->
+ <div class="relative">
+ <button
+ onclick={() => showSettings = !showSettings}
+ class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg {showSettings ? 'bg-white/10 text-white' : ''}"
+ title="Editor Settings"
+ >
+ <Settings size={20} />
+ </button>
+
+ {#if showSettings}
+ <div class="absolute right-0 top-full mt-2 w-48 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl p-2 z-50 flex flex-col gap-1">
+ <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer">
+ <input type="checkbox" bind:checked={showLineNumbers} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" />
+ <span class="text-sm text-zinc-300">Line Numbers</span>
+ </label>
+ <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer">
+ <input type="checkbox" bind:checked={showStatusBar} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" />
+ <span class="text-sm text-zinc-300">Cursor Status</span>
+ </label>
+ </div>
+ {/if}
+ </div>
+
+ <button
+ onclick={() => settingsState.closeConfigEditor()}
+ class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg"
+ title="Close (Esc)"
+ >
+ <X size={20} />
+ </button>
+ </div>
+ </div>
+
+ <!-- Error Banner -->
+ {#if localError || settingsState.configEditorError}
+ <div class="bg-red-500/10 border-b border-red-500/20 p-3 flex items-start gap-3 animate-in slide-in-from-top-2 z-10 relative">
+ <AlertCircle size={16} class="text-red-400 mt-0.5 shrink-0" />
+ <p class="text-xs text-red-300 font-mono whitespace-pre-wrap">{localError || settingsState.configEditorError}</p>
+ </div>
+ {/if}
+
+ <!-- Editor Body (Flex row for line numbers + code) -->
+ <div class="flex-1 flex overflow-hidden relative bg-[#1d1f21]">
+ <!-- Line Numbers -->
+ {#if showLineNumbers}
+ <div
+ bind:this={lineNumbersRef}
+ class="pt-4 pb-4 pr-3 pl-2 text-right text-zinc-600 bg-[#1d1f21] border-r border-zinc-700/50 font-mono select-none overflow-hidden min-w-[3rem]"
+ aria-hidden="true"
+ >
+ {#each lines as _, i}
+ <div class="leading-[20px] text-[13px]">{i + 1}</div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Code Area -->
+ <div class="flex-1 relative overflow-hidden group">
+ <!-- Highlighted Code (Background) -->
+ <pre
+ bind:this={preRef}
+ aria-hidden="true"
+ class="absolute inset-0 w-full h-full p-4 m-0 bg-transparent pointer-events-none overflow-hidden whitespace-pre font-mono text-sm leading-relaxed"
+ ><code class="language-json">{@html highlightedCode}</code></pre>
+
+ <!-- Textarea (Foreground) -->
+ <textarea
+ bind:this={textareaRef}
+ bind:value={content}
+ oninput={handleInput}
+ onkeydown={handleKeydown}
+ onscroll={handleScroll}
+ onmouseup={updateCursor}
+ onkeyup={updateCursor}
+ onclick={() => showSettings = false}
+ class="absolute inset-0 w-full h-full p-4 bg-transparent text-transparent caret-white font-mono text-sm leading-relaxed resize-none focus:outline-none whitespace-pre overflow-auto z-10 selection:bg-indigo-500/30"
+ spellcheck="false"
+ {...textareaAttrs}
+ ></textarea>
+ </div>
+ </div>
+
+ <!-- Footer -->
+ <div class="p-3 border-t border-zinc-700 bg-[#1d1f21] flex justify-between items-center z-20 relative">
+ <div class="text-xs text-zinc-500 flex gap-4 items-center">
+ {#if showStatusBar}
+ <div class="flex gap-3 font-mono border-r border-zinc-700 pr-4 mr-1">
+ <span>Ln {cursorLine}</span>
+ <span>Col {cursorCol}</span>
+ </div>
+ {/if}
+ <span class="hidden sm:inline"><span class="bg-white/10 px-1.5 py-0.5 rounded text-zinc-300">Ctrl+S</span> save</span>
+ </div>
+ <div class="flex gap-3">
+ <button
+ onclick={() => settingsState.closeConfigEditor()}
+ class="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-medium transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={() => handleSave(false)}
+ disabled={isSaving || !!localError}
+ class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
+ title={localError ? "Fix errors before saving" : "Save changes"}
+ >
+ {#if isSaving}
+ <div class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
+ Saving...
+ {:else}
+ <Save size={16} />
+ Save
+ {/if}
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<style>
+ /* Ensure exact font match */
+ pre, textarea {
+ font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
+ font-size: 13px !important;
+ line-height: 20px !important;
+ letter-spacing: 0px !important;
+ tab-size: 2;
+ }
+
+ /* Hide scrollbar for pre but keep it functional for textarea */
+ pre::-webkit-scrollbar {
+ display: none;
+ }
+
+ /* Override Prism background and font weights for alignment */
+ :global(pre[class*="language-"]), :global(code[class*="language-"]) {
+ background: transparent !important;
+ text-shadow: none !important;
+ box-shadow: none !important;
+ }
+
+ /* CRITICAL: Force normal weight to match textarea */
+ :global(.token) {
+ font-weight: normal !important;
+ font-style: normal !important;
+ }
+</style>
diff --git a/packages/ui/src/components/CustomSelect.svelte b/packages/ui/src/components/CustomSelect.svelte
new file mode 100644
index 0000000..0767471
--- /dev/null
+++ b/packages/ui/src/components/CustomSelect.svelte
@@ -0,0 +1,173 @@
+<script lang="ts">
+ import { ChevronDown, Check } from 'lucide-svelte';
+
+ interface Option {
+ value: string;
+ label: string;
+ disabled?: boolean;
+ }
+
+ interface Props {
+ options: Option[];
+ value: string;
+ placeholder?: string;
+ disabled?: boolean;
+ class?: string;
+ allowCustom?: boolean; // New prop to allow custom input
+ onchange?: (value: string) => void;
+ }
+
+ let {
+ options,
+ value = $bindable(),
+ placeholder = "Select...",
+ disabled = false,
+ class: className = "",
+ allowCustom = false,
+ onchange
+ }: Props = $props();
+
+ let isOpen = $state(false);
+ let containerRef: HTMLDivElement;
+ let customInput = $state(""); // State for custom input
+
+ let selectedOption = $derived(options.find(o => o.value === value));
+ // Display label: if option exists use its label, otherwise if custom is allowed use raw value, else placeholder
+ let displayLabel = $derived(selectedOption ? selectedOption.label : (allowCustom && value ? value : placeholder));
+
+ function toggle() {
+ if (!disabled) {
+ isOpen = !isOpen;
+ // When opening, if current value is custom (not in options), pre-fill input
+ if (isOpen && allowCustom && !selectedOption) {
+ customInput = value;
+ }
+ }
+ }
+
+ function select(option: Option) {
+ if (option.disabled) return;
+ value = option.value;
+ isOpen = false;
+ onchange?.(option.value);
+ }
+
+ function handleCustomSubmit() {
+ if (!customInput.trim()) return;
+ value = customInput.trim();
+ isOpen = false;
+ onchange?.(value);
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (disabled) return;
+
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ toggle();
+ } else if (e.key === 'Escape') {
+ isOpen = false;
+ } else if (e.key === 'ArrowDown' && isOpen) {
+ e.preventDefault();
+ const currentIndex = options.findIndex(o => o.value === value);
+ const nextIndex = Math.min(currentIndex + 1, options.length - 1);
+ if (!options[nextIndex].disabled) {
+ value = options[nextIndex].value;
+ }
+ } else if (e.key === 'ArrowUp' && isOpen) {
+ e.preventDefault();
+ const currentIndex = options.findIndex(o => o.value === value);
+ const prevIndex = Math.max(currentIndex - 1, 0);
+ if (!options[prevIndex].disabled) {
+ value = options[prevIndex].value;
+ }
+ }
+ }
+
+ function handleClickOutside(e: MouseEvent) {
+ if (containerRef && !containerRef.contains(e.target as Node)) {
+ isOpen = false;
+ }
+ }
+
+ $effect(() => {
+ if (isOpen) {
+ document.addEventListener('click', handleClickOutside);
+ return () => document.removeEventListener('click', handleClickOutside);
+ }
+ });
+</script>
+
+<div
+ bind:this={containerRef}
+ class="relative {className}"
+>
+ <!-- Trigger Button -->
+ <button
+ type="button"
+ onclick={toggle}
+ onkeydown={handleKeydown}
+ {disabled}
+ class="w-full flex items-center justify-between gap-2 px-3 py-2 pr-8 text-left
+ bg-zinc-900 border border-zinc-700 rounded-md text-sm text-zinc-200
+ hover:border-zinc-600 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+ transition-colors cursor-pointer outline-none
+ disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-700"
+ >
+ <span class="truncate {(!selectedOption && !value) ? 'text-zinc-500' : ''}">
+ {displayLabel}
+ </span>
+ <ChevronDown
+ size={14}
+ class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}"
+ />
+ </button>
+
+ <!-- Dropdown Menu -->
+ {#if isOpen}
+ <div
+ class="absolute z-50 w-full mt-1 py-1 bg-zinc-900 border border-zinc-700 rounded-md shadow-xl
+ max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 flex flex-col"
+ >
+ {#if allowCustom}
+ <div class="px-2 py-2 border-b border-zinc-700/50 mb-1">
+ <div class="flex gap-2">
+ <input
+ type="text"
+ bind:value={customInput}
+ placeholder="Custom value..."
+ class="flex-1 bg-black/30 border border-zinc-700 rounded px-2 py-1 text-xs text-white focus:border-indigo-500 outline-none"
+ onkeydown={(e) => e.key === 'Enter' && handleCustomSubmit()}
+ onclick={(e) => e.stopPropagation()}
+ />
+ <button
+ onclick={(e) => { e.stopPropagation(); handleCustomSubmit(); }}
+ class="px-2 py-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded text-xs transition-colors"
+ >
+ Set
+ </button>
+ </div>
+ </div>
+ {/if}
+
+ {#each options as option}
+ <button
+ type="button"
+ onclick={() => select(option)}
+ disabled={option.disabled}
+ class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
+ transition-colors outline-none
+ {option.value === value
+ ? 'bg-indigo-600 text-white'
+ : 'text-zinc-300 hover:bg-zinc-800'}
+ {option.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}"
+ >
+ <span class="truncate">{option.label}</span>
+ {#if option.value === value}
+ <Check size={14} class="shrink-0 ml-2" />
+ {/if}
+ </button>
+ {/each}
+ </div>
+ {/if}
+</div>
diff --git a/packages/ui/src/components/HomeView.svelte b/packages/ui/src/components/HomeView.svelte
new file mode 100644
index 0000000..573d9da
--- /dev/null
+++ b/packages/ui/src/components/HomeView.svelte
@@ -0,0 +1,271 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import { gameState } from '../stores/game.svelte';
+ import { releasesState } from '../stores/releases.svelte';
+ import { Calendar, ExternalLink } from 'lucide-svelte';
+ import { getSaturnEffect } from './ParticleBackground.svelte';
+
+ type Props = {
+ mouseX: number;
+ mouseY: number;
+ };
+ let { mouseX = 0, mouseY = 0 }: Props = $props();
+
+ // Saturn effect mouse interaction handlers
+ function handleSaturnMouseDown(e: MouseEvent) {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleMouseDown(e.clientX);
+ }
+ }
+
+ function handleSaturnMouseMove(e: MouseEvent) {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleMouseMove(e.clientX);
+ }
+ }
+
+ function handleSaturnMouseUp() {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleMouseUp();
+ }
+ }
+
+ function handleSaturnMouseLeave() {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleMouseUp();
+ }
+ }
+
+ function handleSaturnTouchStart(e: TouchEvent) {
+ if (e.touches.length === 1) {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleTouchStart(e.touches[0].clientX);
+ }
+ }
+ }
+
+ function handleSaturnTouchMove(e: TouchEvent) {
+ if (e.touches.length === 1) {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleTouchMove(e.touches[0].clientX);
+ }
+ }
+ }
+
+ function handleSaturnTouchEnd() {
+ const effect = getSaturnEffect();
+ if (effect) {
+ effect.handleTouchEnd();
+ }
+ }
+
+ onMount(() => {
+ releasesState.loadReleases();
+ });
+
+ function formatDate(dateString: string) {
+ return new Date(dateString).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ });
+ }
+
+ function escapeHtml(unsafe: string) {
+ return unsafe
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#039;");
+ }
+
+ // Enhanced markdown parser with Emoji and GitHub specific features
+ function formatBody(body: string) {
+ if (!body) return '';
+
+ // Escape HTML first to prevent XSS
+ let processed = escapeHtml(body);
+
+ // Emoji map (common GitHub emojis)
+ const emojiMap: Record<string, string> = {
+ ':tada:': '🎉', ':sparkles:': '✨', ':bug:': '🐛', ':memo:': '📝',
+ ':rocket:': '🚀', ':white_check_mark:': '✅', ':construction:': '🚧',
+ ':recycle:': '♻️', ':wrench:': '🔧', ':package:': '📦',
+ ':arrow_up:': '⬆️', ':arrow_down:': '⬇️', ':warning:': '⚠️',
+ ':fire:': '🔥', ':heart:': '❤️', ':star:': '⭐', ':zap:': '⚡',
+ ':art:': '🎨', ':lipstick:': '💄', ':globe_with_meridians:': '🌐'
+ };
+
+ // Replace emojis
+ processed = processed.replace(/:[a-z0-9_]+:/g, (match) => emojiMap[match] || match);
+
+ // GitHub commit hash linking (simple version for 7-40 hex chars inside backticks)
+ processed = processed.replace(/`([0-9a-f]{7,40})`/g, (match, hash) => {
+ return `<a href="https://github.com/HydroRoll-Team/DropOut/commit/${hash}" target="_blank" class="text-emerald-500 hover:underline font-mono bg-emerald-500/10 px-1 rounded text-[10px] py-0.5 transition-colors border border-emerald-500/20 hover:border-emerald-500/50">${hash.substring(0, 7)}</a>`;
+ });
+
+ // Auto-link users (@user)
+ processed = processed.replace(/@([a-zA-Z0-9-]+)/g, '<a href="https://github.com/$1" target="_blank" class="text-zinc-300 hover:text-white hover:underline font-medium">@$1</a>');
+
+ return processed.split('\n').map(line => {
+ line = line.trim();
+
+ // Formatting helper
+ const formatLine = (text: string) => text
+ .replace(/\*\*(.*?)\*\*/g, '<strong class="text-zinc-200">$1</strong>')
+ .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em class="text-zinc-400 italic">$1</em>')
+ .replace(/`([^`]+)`/g, '<code class="bg-zinc-800 px-1 py-0.5 rounded text-xs text-zinc-300 font-mono border border-white/5 break-all whitespace-normal">$1</code>')
+ .replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" class="text-indigo-400 hover:text-indigo-300 hover:underline decoration-indigo-400/30 break-all">$1</a>');
+
+ // Lists
+ if (line.startsWith('- ') || line.startsWith('* ')) {
+ return `<li class="ml-4 list-disc marker:text-zinc-600 mb-1 pl-1 text-zinc-400">${formatLine(line.substring(2))}</li>`;
+ }
+
+ // Headers
+ if (line.startsWith('##')) {
+ return `<h3 class="text-sm font-bold mt-6 mb-3 text-zinc-100 flex items-center gap-2 border-b border-white/5 pb-2 uppercase tracking-wide">${line.replace(/^#+\s+/, '')}</h3>`;
+ }
+ if (line.startsWith('#')) {
+ return `<h3 class="text-base font-bold mt-6 mb-3 text-white">${line.replace(/^#+\s+/, '')}</h3>`;
+ }
+
+ // Blockquotes
+ if (line.startsWith('> ')) {
+ return `<blockquote class="border-l-2 border-zinc-700 pl-4 py-1 my-2 text-zinc-500 italic bg-white/5 rounded-r-sm">${formatLine(line.substring(2))}</blockquote>`;
+ }
+
+ // Empty lines
+ if (line === '') return '<div class="h-2"></div>';
+
+ // Paragraphs
+ return `<p class="mb-1.5 leading-relaxed">${formatLine(line)}</p>`;
+ }).join('');
+ }
+</script>
+
+<div class="absolute inset-0 z-0 overflow-hidden pointer-events-none">
+ <!-- Fixed Background -->
+ <div class="absolute inset-0 bg-gradient-to-t from-[#09090b] via-[#09090b]/60 to-transparent"></div>
+</div>
+
+<!-- Scrollable Container -->
+<div class="relative z-10 h-full {releasesState.isLoading ? 'overflow-hidden' : 'overflow-y-auto custom-scrollbar scroll-smooth'}">
+
+ <!-- Hero Section (Full Height) - Interactive area for Saturn rotation -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div
+ class="min-h-full flex flex-col justify-end p-12 pb-32 cursor-grab active:cursor-grabbing select-none"
+ onmousedown={handleSaturnMouseDown}
+ onmousemove={handleSaturnMouseMove}
+ onmouseup={handleSaturnMouseUp}
+ onmouseleave={handleSaturnMouseLeave}
+ ontouchstart={handleSaturnTouchStart}
+ ontouchmove={handleSaturnTouchMove}
+ ontouchend={handleSaturnTouchEnd}
+ >
+ <!-- 3D Floating Hero Text -->
+ <div
+ class="transition-transform duration-200 ease-out origin-bottom-left"
+ style:transform={`perspective(1000px) rotateX(${mouseY * -1}deg) rotateY(${mouseX * 1}deg)`}
+ >
+ <div class="flex items-center gap-3 mb-6">
+ <div class="h-px w-12 bg-white/50"></div>
+ <span class="text-xs font-mono font-bold tracking-[0.2em] text-white/50 uppercase">Launcher Active</span>
+ </div>
+
+ <h1
+ class="text-8xl font-black tracking-tighter text-white mb-6 leading-none"
+ >
+ MINECRAFT
+ </h1>
+
+ <div class="flex items-center gap-4">
+ <div
+ class="bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-sm text-xs font-bold uppercase tracking-widest text-white shadow-sm"
+ >
+ Java Edition
+ </div>
+ <div class="h-4 w-px bg-white/20"></div>
+ <div class="text-xl font-light text-zinc-400">
+ Latest Release <span class="text-white font-medium">{gameState.latestRelease?.id || '...'}</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Action Area -->
+ <div class="mt-8 flex gap-4">
+ <div class="text-zinc-500 text-sm font-mono">
+ > Ready to launch session.
+ </div>
+ </div>
+
+ <!-- Scroll Hint -->
+ {#if !releasesState.isLoading && releasesState.releases.length > 0}
+ <div class="absolute bottom-10 left-12 animate-bounce text-zinc-600 flex flex-col items-center gap-2 w-fit opacity-50 hover:opacity-100 transition-opacity">
+ <span class="text-[10px] font-mono uppercase tracking-widest">Scroll for Updates</span>
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Changelog / Updates Section -->
+ <div class="bg-[#09090b] relative z-20 px-12 pb-24 pt-12 border-t border-white/5 min-h-[50vh]">
+ <div class="max-w-4xl">
+ <h2 class="text-2xl font-bold text-white mb-10 flex items-center gap-3">
+ <span class="w-1.5 h-8 bg-emerald-500 rounded-sm"></span>
+ LATEST UPDATES
+ </h2>
+
+ {#if releasesState.isLoading}
+ <div class="flex flex-col gap-8">
+ {#each Array(3) as _}
+ <div class="h-48 bg-white/5 rounded-sm animate-pulse border border-white/5"></div>
+ {/each}
+ </div>
+ {:else if releasesState.error}
+ <div class="p-6 border border-red-500/20 bg-red-500/10 text-red-400 rounded-sm">
+ Failed to load updates: {releasesState.error}
+ </div>
+ {:else if releasesState.releases.length === 0}
+ <div class="text-zinc-500 italic">No releases found.</div>
+ {:else}
+ <div class="space-y-12">
+ {#each releasesState.releases as release}
+ <div class="group relative pl-8 border-l border-white/10 pb-4 last:pb-0 last:border-l-0">
+ <!-- Timeline Dot -->
+ <div class="absolute -left-[5px] top-1.5 w-2.5 h-2.5 rounded-full bg-zinc-800 border border-zinc-600 group-hover:bg-emerald-500 group-hover:border-emerald-400 transition-colors"></div>
+
+ <div class="flex items-baseline gap-4 mb-3">
+ <h3 class="text-xl font-bold text-white group-hover:text-emerald-400 transition-colors">
+ {release.name || release.tag_name}
+ </h3>
+ <div class="text-xs font-mono text-zinc-500 flex items-center gap-2">
+ <Calendar size={12} />
+ {formatDate(release.published_at)}
+ </div>
+ </div>
+
+ <div class="bg-zinc-900/50 border border-white/5 hover:border-white/10 rounded-sm p-6 text-zinc-400 text-sm leading-relaxed transition-colors overflow-hidden">
+ <div class="prose prose-invert prose-sm max-w-none prose-p:text-zinc-400 prose-headings:text-zinc-200 prose-ul:my-2 prose-li:my-0 break-words whitespace-normal">
+ {@html formatBody(release.body)}
+ </div>
+ </div>
+
+ <a href={release.html_url} target="_blank" class="inline-flex items-center gap-2 mt-3 text-[10px] font-bold uppercase tracking-wider text-zinc-600 hover:text-white transition-colors">
+ View full changelog on GitHub <ExternalLink size={10} />
+ </a>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ </div>
+</div>
diff --git a/packages/ui/src/components/InstanceCreationModal.svelte b/packages/ui/src/components/InstanceCreationModal.svelte
new file mode 100644
index 0000000..c54cb98
--- /dev/null
+++ b/packages/ui/src/components/InstanceCreationModal.svelte
@@ -0,0 +1,485 @@
+<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
+ import { X, ChevronLeft, ChevronRight, Loader2, Search } from "lucide-svelte";
+ import { instancesState } from "../stores/instances.svelte";
+ import { gameState } from "../stores/game.svelte";
+ import type { Version, Instance, FabricLoaderEntry, ForgeVersion } from "../types";
+
+ interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+ }
+
+ let { isOpen, onClose }: Props = $props();
+
+ // Wizard steps: 1 = Name, 2 = Version, 3 = Mod Loader
+ let currentStep = $state(1);
+ let instanceName = $state("");
+ let selectedVersion = $state<Version | null>(null);
+ let modLoaderType = $state<"vanilla" | "fabric" | "forge">("vanilla");
+ let selectedFabricLoader = $state("");
+ let selectedForgeLoader = $state("");
+ let creating = $state(false);
+ let errorMessage = $state("");
+
+ // Mod loader lists
+ let fabricLoaders = $state<FabricLoaderEntry[]>([]);
+ let forgeVersions = $state<ForgeVersion[]>([]);
+ let loadingLoaders = $state(false);
+
+ // Version list filtering
+ let versionSearch = $state("");
+ let versionFilter = $state<"all" | "release" | "snapshot">("release");
+
+ // Filtered versions
+ let filteredVersions = $derived(() => {
+ let versions = gameState.versions || [];
+
+ // Filter by type
+ if (versionFilter !== "all") {
+ versions = versions.filter((v) => v.type === versionFilter);
+ }
+
+ // Search filter
+ if (versionSearch) {
+ versions = versions.filter((v) =>
+ v.id.toLowerCase().includes(versionSearch.toLowerCase())
+ );
+ }
+
+ return versions;
+ });
+
+ // Fetch mod loaders when entering step 3
+ async function loadModLoaders() {
+ if (!selectedVersion) return;
+
+ loadingLoaders = true;
+ try {
+ if (modLoaderType === "fabric") {
+ const loaders = await invoke<FabricLoaderEntry[]>("get_fabric_loaders_for_version", {
+ gameVersion: selectedVersion.id,
+ });
+ fabricLoaders = loaders;
+ if (loaders.length > 0) {
+ selectedFabricLoader = loaders[0].loader.version;
+ }
+ } else if (modLoaderType === "forge") {
+ const versions = await invoke<ForgeVersion[]>("get_forge_versions_for_game", {
+ gameVersion: selectedVersion.id,
+ });
+ forgeVersions = versions;
+ if (versions.length > 0) {
+ selectedForgeLoader = versions[0].version;
+ }
+ }
+ } catch (err) {
+ errorMessage = `Failed to load ${modLoaderType} versions: ${err}`;
+ } finally {
+ loadingLoaders = false;
+ }
+ }
+
+ // Watch for mod loader type changes and load loaders
+ $effect(() => {
+ if (currentStep === 3 && modLoaderType !== "vanilla") {
+ loadModLoaders();
+ }
+ });
+
+ // Reset modal state
+ function resetModal() {
+ currentStep = 1;
+ instanceName = "";
+ selectedVersion = null;
+ modLoaderType = "vanilla";
+ selectedFabricLoader = "";
+ selectedForgeLoader = "";
+ creating = false;
+ errorMessage = "";
+ versionSearch = "";
+ versionFilter = "release";
+ }
+
+ function handleClose() {
+ if (!creating) {
+ resetModal();
+ onClose();
+ }
+ }
+
+ function goToStep(step: number) {
+ errorMessage = "";
+ currentStep = step;
+ }
+
+ function validateStep1() {
+ if (!instanceName.trim()) {
+ errorMessage = "Please enter an instance name";
+ return false;
+ }
+ return true;
+ }
+
+ function validateStep2() {
+ if (!selectedVersion) {
+ errorMessage = "Please select a Minecraft version";
+ return false;
+ }
+ return true;
+ }
+
+ async function handleNext() {
+ errorMessage = "";
+
+ if (currentStep === 1) {
+ if (validateStep1()) {
+ goToStep(2);
+ }
+ } else if (currentStep === 2) {
+ if (validateStep2()) {
+ goToStep(3);
+ }
+ }
+ }
+
+ async function handleCreate() {
+ if (!validateStep1() || !validateStep2()) return;
+
+ creating = true;
+ errorMessage = "";
+
+ try {
+ // Step 1: Create instance
+ const instance: Instance = await invoke("create_instance", {
+ name: instanceName.trim(),
+ });
+
+ // Step 2: Install vanilla version
+ await invoke("install_version", {
+ instanceId: instance.id,
+ versionId: selectedVersion!.id,
+ });
+
+ // Step 3: Install mod loader if selected
+ if (modLoaderType === "fabric" && selectedFabricLoader) {
+ await invoke("install_fabric", {
+ instanceId: instance.id,
+ gameVersion: selectedVersion!.id,
+ loaderVersion: selectedFabricLoader,
+ });
+ } else if (modLoaderType === "forge" && selectedForgeLoader) {
+ await invoke("install_forge", {
+ instanceId: instance.id,
+ gameVersion: selectedVersion!.id,
+ forgeVersion: selectedForgeLoader,
+ });
+ } else {
+ // Update instance with vanilla version_id
+ await invoke("update_instance", {
+ instance: { ...instance, version_id: selectedVersion!.id },
+ });
+ }
+
+ // Reload instances
+ await instancesState.loadInstances();
+
+ // Success! Close modal
+ resetModal();
+ onClose();
+ } catch (error) {
+ errorMessage = String(error);
+ creating = false;
+ }
+ }
+</script>
+
+{#if isOpen}
+ <div
+ class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
+ role="dialog"
+ aria-modal="true"
+ >
+ <div
+ class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col"
+ >
+ <!-- Header -->
+ <div
+ class="flex items-center justify-between p-6 border-b border-zinc-700"
+ >
+ <div>
+ <h2 class="text-xl font-bold text-white">Create New Instance</h2>
+ <p class="text-sm text-zinc-400 mt-1">
+ Step {currentStep} of 3
+ </p>
+ </div>
+ <button
+ onclick={handleClose}
+ disabled={creating}
+ class="p-2 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors disabled:opacity-50"
+ >
+ <X size={20} />
+ </button>
+ </div>
+
+ <!-- Progress indicator -->
+ <div class="flex gap-2 px-6 pt-4">
+ <div
+ class="flex-1 h-1 rounded-full transition-colors {currentStep >= 1
+ ? 'bg-indigo-500'
+ : 'bg-zinc-700'}"
+ ></div>
+ <div
+ class="flex-1 h-1 rounded-full transition-colors {currentStep >= 2
+ ? 'bg-indigo-500'
+ : 'bg-zinc-700'}"
+ ></div>
+ <div
+ class="flex-1 h-1 rounded-full transition-colors {currentStep >= 3
+ ? 'bg-indigo-500'
+ : 'bg-zinc-700'}"
+ ></div>
+ </div>
+
+ <!-- Content -->
+ <div class="flex-1 overflow-y-auto p-6">
+ {#if currentStep === 1}
+ <!-- Step 1: Name -->
+ <div class="space-y-4">
+ <div>
+ <label
+ for="instance-name"
+ class="block text-sm font-medium text-white/90 mb-2"
+ >Instance Name</label
+ >
+ <input
+ id="instance-name"
+ type="text"
+ bind:value={instanceName}
+ placeholder="My Minecraft Instance"
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ disabled={creating}
+ />
+ </div>
+ <p class="text-xs text-zinc-400">
+ Give your instance a memorable name
+ </p>
+ </div>
+ {:else if currentStep === 2}
+ <!-- Step 2: Version Selection -->
+ <div class="space-y-4">
+ <div class="flex gap-4">
+ <div class="flex-1 relative">
+ <Search
+ size={16}
+ class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
+ />
+ <input
+ type="text"
+ bind:value={versionSearch}
+ placeholder="Search versions..."
+ class="w-full pl-10 pr-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ />
+ </div>
+ <div class="flex gap-2">
+ {#each [
+ { value: "all", label: "All" },
+ { value: "release", label: "Release" },
+ { value: "snapshot", label: "Snapshot" },
+ ] as filter}
+ <button
+ onclick={() => {
+ versionFilter = filter.value as "all" | "release" | "snapshot";
+ }}
+ class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {versionFilter ===
+ filter.value
+ ? 'bg-indigo-600 text-white'
+ : 'bg-zinc-800 text-zinc-400 hover:text-white'}"
+ >
+ {filter.label}
+ </button>
+ {/each}
+ </div>
+ </div>
+
+ <div class="max-h-96 overflow-y-auto space-y-2">
+ {#each filteredVersions() as version}
+ <button
+ onclick={() => (selectedVersion = version)}
+ class="w-full p-3 rounded-lg border transition-colors text-left {selectedVersion?.id ===
+ version.id
+ ? 'bg-indigo-600/20 border-indigo-500 text-white'
+ : 'bg-zinc-800 border-zinc-700 text-zinc-300 hover:border-zinc-600'}"
+ >
+ <div class="flex items-center justify-between">
+ <span class="font-medium">{version.id}</span>
+ <span
+ class="text-xs px-2 py-1 rounded-full {version.type ===
+ 'release'
+ ? 'bg-green-500/20 text-green-400'
+ : 'bg-yellow-500/20 text-yellow-400'}"
+ >
+ {version.type}
+ </span>
+ </div>
+ </button>
+ {/each}
+
+ {#if filteredVersions().length === 0}
+ <div class="text-center py-8 text-zinc-500">
+ No versions found
+ </div>
+ {/if}
+ </div>
+ </div>
+ {:else if currentStep === 3}
+ <!-- Step 3: Mod Loader -->
+ <div class="space-y-4">
+ <div>
+ <div class="text-sm font-medium text-white/90 mb-3">
+ Mod Loader Type
+ </div>
+ <div class="flex gap-3">
+ {#each [
+ { value: "vanilla", label: "Vanilla" },
+ { value: "fabric", label: "Fabric" },
+ { value: "forge", label: "Forge" },
+ ] as loader}
+ <button
+ onclick={() => {
+ modLoaderType = loader.value as "vanilla" | "fabric" | "forge";
+ }}
+ class="flex-1 px-4 py-3 rounded-lg text-sm font-medium transition-colors {modLoaderType ===
+ loader.value
+ ? 'bg-indigo-600 text-white'
+ : 'bg-zinc-800 text-zinc-400 hover:text-white'}"
+ >
+ {loader.label}
+ </button>
+ {/each}
+ </div>
+ </div>
+
+ {#if modLoaderType === "fabric"}
+ <div>
+ <label for="fabric-loader" class="block text-sm font-medium text-white/90 mb-2">
+ Fabric Loader Version
+ </label>
+ {#if loadingLoaders}
+ <div class="flex items-center gap-2 text-zinc-400">
+ <Loader2 size={16} class="animate-spin" />
+ Loading Fabric versions...
+ </div>
+ {:else if fabricLoaders.length > 0}
+ <select
+ id="fabric-loader"
+ bind:value={selectedFabricLoader}
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ >
+ {#each fabricLoaders as loader}
+ <option value={loader.loader.version}>
+ {loader.loader.version} {loader.loader.stable ? "(Stable)" : "(Beta)"}
+ </option>
+ {/each}
+ </select>
+ {:else}
+ <p class="text-sm text-red-400">No Fabric loaders available for this version</p>
+ {/if}
+ </div>
+ {:else if modLoaderType === "forge"}
+ <div>
+ <label for="forge-version" class="block text-sm font-medium text-white/90 mb-2">
+ Forge Version
+ </label>
+ {#if loadingLoaders}
+ <div class="flex items-center gap-2 text-zinc-400">
+ <Loader2 size={16} class="animate-spin" />
+ Loading Forge versions...
+ </div>
+ {:else if forgeVersions.length > 0}
+ <select
+ id="forge-version"
+ bind:value={selectedForgeLoader}
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ >
+ {#each forgeVersions as version}
+ <option value={version.version}>
+ {version.version}
+ </option>
+ {/each}
+ </select>
+ {:else}
+ <p class="text-sm text-red-400">No Forge versions available for this version</p>
+ {/if}
+ </div>
+ {:else if modLoaderType === "vanilla"}
+ <p class="text-sm text-zinc-400">
+ Create a vanilla Minecraft instance without any mod loaders
+ </p>
+ {/if}
+ </div>
+ {/if}
+
+ {#if errorMessage}
+ <div
+ class="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm"
+ >
+ {errorMessage}
+ </div>
+ {/if}
+ </div>
+
+ <!-- Footer -->
+ <div
+ class="flex items-center justify-between gap-3 p-6 border-t border-zinc-700"
+ >
+ <button
+ onclick={() => goToStep(currentStep - 1)}
+ disabled={currentStep === 1 || creating}
+ class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
+ >
+ <ChevronLeft size={16} />
+ Back
+ </button>
+
+ <div class="flex gap-3">
+ <button
+ onclick={handleClose}
+ disabled={creating}
+ class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+
+ {#if currentStep < 3}
+ <button
+ onclick={handleNext}
+ disabled={creating}
+ class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
+ >
+ Next
+ <ChevronRight size={16} />
+ </button>
+ {:else}
+ <button
+ onclick={handleCreate}
+ disabled={creating ||
+ !instanceName.trim() ||
+ !selectedVersion ||
+ (modLoaderType === "fabric" && !selectedFabricLoader) ||
+ (modLoaderType === "forge" && !selectedForgeLoader)}
+ class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
+ >
+ {#if creating}
+ <Loader2 size={16} class="animate-spin" />
+ Creating...
+ {:else}
+ Create Instance
+ {/if}
+ </button>
+ {/if}
+ </div>
+ </div>
+ </div>
+ </div>
+{/if}
diff --git a/packages/ui/src/components/InstanceEditorModal.svelte b/packages/ui/src/components/InstanceEditorModal.svelte
new file mode 100644
index 0000000..0856d93
--- /dev/null
+++ b/packages/ui/src/components/InstanceEditorModal.svelte
@@ -0,0 +1,439 @@
+<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
+ import { X, Save, Loader2, Trash2, FolderOpen } from "lucide-svelte";
+ import { instancesState } from "../stores/instances.svelte";
+ import { gameState } from "../stores/game.svelte";
+ import { settingsState } from "../stores/settings.svelte";
+ import type { Instance, FileInfo, FabricLoaderEntry, ForgeVersion } from "../types";
+ import ModLoaderSelector from "./ModLoaderSelector.svelte";
+
+ interface Props {
+ isOpen: boolean;
+ instance: Instance | null;
+ onClose: () => void;
+ }
+
+ let { isOpen, instance, onClose }: Props = $props();
+
+ // Tabs: "info" | "version" | "files" | "settings"
+ let activeTab = $state<"info" | "version" | "files" | "settings">("info");
+ let saving = $state(false);
+ let errorMessage = $state("");
+
+ // Info tab state
+ let editName = $state("");
+ let editNotes = $state("");
+
+ // Version tab state
+ let fabricLoaders = $state<FabricLoaderEntry[]>([]);
+ let forgeVersions = $state<ForgeVersion[]>([]);
+ let loadingVersions = $state(false);
+
+ // Files tab state
+ let selectedFileFolder = $state<"mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots">("mods");
+ let fileList = $state<FileInfo[]>([]);
+ let loadingFiles = $state(false);
+ let deletingPath = $state<string | null>(null);
+
+ // Settings tab state
+ let editMemoryMin = $state(0);
+ let editMemoryMax = $state(0);
+ let editJavaArgs = $state("");
+
+ // Initialize form when instance changes
+ $effect(() => {
+ if (isOpen && instance) {
+ editName = instance.name;
+ editNotes = instance.notes || "";
+ editMemoryMin = instance.memory_override?.min || settingsState.settings.min_memory || 512;
+ editMemoryMax = instance.memory_override?.max || settingsState.settings.max_memory || 2048;
+ editJavaArgs = instance.jvm_args_override || "";
+ errorMessage = "";
+ }
+ });
+
+ // Load files when switching to files tab
+ $effect(() => {
+ if (isOpen && instance && activeTab === "files") {
+ loadFileList();
+ }
+ });
+
+ // Load file list for selected folder
+ async function loadFileList() {
+ if (!instance) return;
+
+ loadingFiles = true;
+ try {
+ const files = await invoke<FileInfo[]>("list_instance_directory", {
+ instanceId: instance.id,
+ folder: selectedFileFolder,
+ });
+ fileList = files;
+ } catch (err) {
+ errorMessage = `Failed to load files: ${err}`;
+ fileList = [];
+ } finally {
+ loadingFiles = false;
+ }
+ }
+
+ // Change selected folder and reload
+ async function changeFolder(folder: "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots") {
+ selectedFileFolder = folder;
+ await loadFileList();
+ }
+
+ // Delete a file or directory
+ async function deleteFile(filePath: string) {
+ if (!confirm(`Are you sure you want to delete "${filePath.split("/").pop()}"?`)) {
+ return;
+ }
+
+ deletingPath = filePath;
+ try {
+ await invoke("delete_instance_file", { path: filePath });
+ // Reload file list
+ await loadFileList();
+ } catch (err) {
+ errorMessage = `Failed to delete file: ${err}`;
+ } finally {
+ deletingPath = null;
+ }
+ }
+
+ // Open file in system explorer
+ async function openInExplorer(filePath: string) {
+ try {
+ await invoke("open_file_explorer", { path: filePath });
+ } catch (err) {
+ errorMessage = `Failed to open file explorer: ${err}`;
+ }
+ }
+
+ // Save instance changes
+ async function saveChanges() {
+ if (!instance) return;
+ if (!editName.trim()) {
+ errorMessage = "Instance name cannot be empty";
+ return;
+ }
+
+ saving = true;
+ errorMessage = "";
+
+ try {
+ const updatedInstance: Instance = {
+ ...instance,
+ name: editName.trim(),
+ notes: editNotes.trim() || undefined,
+ memory_override: {
+ min: editMemoryMin,
+ max: editMemoryMax,
+ },
+ jvm_args_override: editJavaArgs.trim() || undefined,
+ };
+
+ await instancesState.updateInstance(updatedInstance);
+ onClose();
+ } catch (err) {
+ errorMessage = `Failed to save instance: ${err}`;
+ } finally {
+ saving = false;
+ }
+ }
+
+ function formatFileSize(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
+ }
+
+ function formatDate(timestamp: number): string {
+ return new Date(timestamp * 1000).toLocaleDateString();
+ }
+</script>
+
+{#if isOpen && instance}
+ <div
+ class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
+ role="dialog"
+ aria-modal="true"
+ >
+ <div
+ class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"
+ >
+ <!-- Header -->
+ <div class="flex items-center justify-between p-6 border-b border-zinc-700">
+ <div>
+ <h2 class="text-xl font-bold text-white">Edit Instance</h2>
+ <p class="text-sm text-zinc-400 mt-1">{instance.name}</p>
+ </div>
+ <button
+ onclick={onClose}
+ disabled={saving}
+ class="p-2 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors disabled:opacity-50"
+ >
+ <X size={20} />
+ </button>
+ </div>
+
+ <!-- Tab Navigation -->
+ <div class="flex gap-1 px-6 pt-4 border-b border-zinc-700">
+ {#each [
+ { id: "info", label: "Info" },
+ { id: "version", label: "Version" },
+ { id: "files", label: "Files" },
+ { id: "settings", label: "Settings" },
+ ] as tab}
+ <button
+ onclick={() => (activeTab = tab.id as any)}
+ class="px-4 py-2 text-sm font-medium transition-colors rounded-t-lg {activeTab === tab.id
+ ? 'bg-indigo-600 text-white'
+ : 'bg-zinc-800 text-zinc-400 hover:text-white'}"
+ >
+ {tab.label}
+ </button>
+ {/each}
+ </div>
+
+ <!-- Content Area -->
+ <div class="flex-1 overflow-y-auto p-6">
+ {#if activeTab === "info"}
+ <!-- Info Tab -->
+ <div class="space-y-4">
+ <div>
+ <label for="instance-name" class="block text-sm font-medium text-white/90 mb-2">
+ Instance Name
+ </label>
+ <input
+ id="instance-name"
+ type="text"
+ bind:value={editName}
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ disabled={saving}
+ />
+ </div>
+
+ <div>
+ <label for="instance-notes" class="block text-sm font-medium text-white/90 mb-2">
+ Notes
+ </label>
+ <textarea
+ id="instance-notes"
+ bind:value={editNotes}
+ rows="4"
+ placeholder="Add notes about this instance..."
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none"
+ disabled={saving}
+ ></textarea>
+ </div>
+
+ <div class="grid grid-cols-2 gap-4 text-sm">
+ <div class="p-3 bg-zinc-800 rounded-lg">
+ <p class="text-zinc-400">Created</p>
+ <p class="text-white font-medium">{formatDate(instance.created_at)}</p>
+ </div>
+ <div class="p-3 bg-zinc-800 rounded-lg">
+ <p class="text-zinc-400">Last Played</p>
+ <p class="text-white font-medium">
+ {instance.last_played ? formatDate(instance.last_played) : "Never"}
+ </p>
+ </div>
+ <div class="p-3 bg-zinc-800 rounded-lg">
+ <p class="text-zinc-400">Game Directory</p>
+ <p class="text-white font-medium text-xs truncate" title={instance.game_dir}>
+ {instance.game_dir.split("/").pop()}
+ </p>
+ </div>
+ <div class="p-3 bg-zinc-800 rounded-lg">
+ <p class="text-zinc-400">Current Version</p>
+ <p class="text-white font-medium">{instance.version_id || "None"}</p>
+ </div>
+ </div>
+ </div>
+ {:else if activeTab === "version"}
+ <!-- Version Tab -->
+ <div class="space-y-4">
+ {#if instance.version_id}
+ <div class="p-4 bg-indigo-500/10 border border-indigo-500/30 rounded-lg">
+ <p class="text-sm text-indigo-400">
+ Currently playing: <span class="font-medium">{instance.version_id}</span>
+ {#if instance.mod_loader}
+ with <span class="capitalize">{instance.mod_loader}</span>
+ {instance.mod_loader_version && `${instance.mod_loader_version}`}
+ {/if}
+ </p>
+ </div>
+ {/if}
+
+ <div>
+ <h3 class="text-sm font-medium text-white/90 mb-4">Change Version or Mod Loader</h3>
+ <ModLoaderSelector
+ selectedGameVersion={instance.version_id || ""}
+ onInstall={(versionId) => {
+ // Version installed, update instance version_id
+ instance.version_id = versionId;
+ saveChanges();
+ }}
+ />
+ </div>
+ </div>
+ {:else if activeTab === "files"}
+ <!-- Files Tab -->
+ <div class="space-y-4">
+ <div class="flex gap-2 flex-wrap">
+ {#each ["mods", "resourcepacks", "shaderpacks", "saves", "screenshots"] as folder}
+ <button
+ onclick={() => changeFolder(folder as any)}
+ class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors {selectedFileFolder ===
+ folder
+ ? "bg-indigo-600 text-white"
+ : "bg-zinc-800 text-zinc-400 hover:text-white"}"
+ >
+ {folder}
+ </button>
+ {/each}
+ </div>
+
+ {#if loadingFiles}
+ <div class="flex items-center gap-2 text-zinc-400 py-8 justify-center">
+ <Loader2 size={16} class="animate-spin" />
+ Loading files...
+ </div>
+ {:else if fileList.length === 0}
+ <div class="text-center py-8 text-zinc-500">
+ No files in this folder
+ </div>
+ {:else}
+ <div class="space-y-2">
+ {#each fileList as file}
+ <div
+ class="flex items-center justify-between p-3 bg-zinc-800 rounded-lg hover:bg-zinc-700 transition-colors"
+ >
+ <div class="flex-1 min-w-0">
+ <p class="font-medium text-white truncate">{file.name}</p>
+ <p class="text-xs text-zinc-400">
+ {file.is_directory ? "Folder" : formatFileSize(file.size)}
+ • {formatDate(file.modified)}
+ </p>
+ </div>
+ <div class="flex gap-2 ml-4">
+ <button
+ onclick={() => openInExplorer(file.path)}
+ title="Open in explorer"
+ class="p-2 rounded-lg hover:bg-zinc-600 text-zinc-400 hover:text-white transition-colors"
+ >
+ <FolderOpen size={16} />
+ </button>
+ <button
+ onclick={() => deleteFile(file.path)}
+ disabled={deletingPath === file.path}
+ title="Delete"
+ class="p-2 rounded-lg hover:bg-red-600/20 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
+ >
+ {#if deletingPath === file.path}
+ <Loader2 size={16} class="animate-spin" />
+ {:else}
+ <Trash2 size={16} />
+ {/if}
+ </button>
+ </div>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ {:else if activeTab === "settings"}
+ <!-- Settings Tab -->
+ <div class="space-y-4">
+ <div>
+ <label for="min-memory" class="block text-sm font-medium text-white/90 mb-2">
+ Minimum Memory (MB)
+ </label>
+ <input
+ id="min-memory"
+ type="number"
+ bind:value={editMemoryMin}
+ min="256"
+ max={editMemoryMax}
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ disabled={saving}
+ />
+ <p class="text-xs text-zinc-400 mt-1">
+ Default: {settingsState.settings.min_memory}MB
+ </p>
+ </div>
+
+ <div>
+ <label for="max-memory" class="block text-sm font-medium text-white/90 mb-2">
+ Maximum Memory (MB)
+ </label>
+ <input
+ id="max-memory"
+ type="number"
+ bind:value={editMemoryMax}
+ min={editMemoryMin}
+ max="16384"
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ disabled={saving}
+ />
+ <p class="text-xs text-zinc-400 mt-1">
+ Default: {settingsState.settings.max_memory}MB
+ </p>
+ </div>
+
+ <div>
+ <label for="java-args" class="block text-sm font-medium text-white/90 mb-2">
+ JVM Arguments (Advanced)
+ </label>
+ <textarea
+ id="java-args"
+ bind:value={editJavaArgs}
+ rows="4"
+ placeholder="-XX:+UnlockExperimentalVMOptions -XX:G1NewCollectionPercentage=20..."
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 font-mono text-sm resize-none"
+ disabled={saving}
+ ></textarea>
+ <p class="text-xs text-zinc-400 mt-1">
+ Leave empty to use global Java arguments
+ </p>
+ </div>
+ </div>
+ {/if}
+
+ {#if errorMessage}
+ <div class="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
+ {errorMessage}
+ </div>
+ {/if}
+ </div>
+
+ <!-- Footer -->
+ <div class="flex items-center justify-end gap-3 p-6 border-t border-zinc-700">
+ <button
+ onclick={onClose}
+ disabled={saving}
+ class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={saveChanges}
+ disabled={saving || !editName.trim()}
+ class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
+ >
+ {#if saving}
+ <Loader2 size={16} class="animate-spin" />
+ Saving...
+ {:else}
+ <Save size={16} />
+ Save Changes
+ {/if}
+ </button>
+ </div>
+ </div>
+ </div>
+{/if}
diff --git a/packages/ui/src/components/InstancesView.svelte b/packages/ui/src/components/InstancesView.svelte
new file mode 100644
index 0000000..5334f9e
--- /dev/null
+++ b/packages/ui/src/components/InstancesView.svelte
@@ -0,0 +1,259 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import { instancesState } from "../stores/instances.svelte";
+ import { Plus, Trash2, Edit2, Copy, Check, X } from "lucide-svelte";
+ import type { Instance } from "../types";
+ import InstanceCreationModal from "./InstanceCreationModal.svelte";
+ import InstanceEditorModal from "./InstanceEditorModal.svelte";
+
+ let showCreateModal = $state(false);
+ let showEditModal = $state(false);
+ let showDeleteConfirm = $state(false);
+ let showDuplicateModal = $state(false);
+ let selectedInstance: Instance | null = $state(null);
+ let editingInstance: Instance | null = $state(null);
+ let newInstanceName = $state("");
+ let duplicateName = $state("");
+
+ onMount(() => {
+ instancesState.loadInstances();
+ });
+
+ function handleCreate() {
+ showCreateModal = true;
+ }
+
+ function handleEdit(instance: Instance) {
+ editingInstance = instance;
+ }
+
+ function handleDelete(instance: Instance) {
+ selectedInstance = instance;
+ showDeleteConfirm = true;
+ }
+
+ function handleDuplicate(instance: Instance) {
+ selectedInstance = instance;
+ duplicateName = `${instance.name} (Copy)`;
+ showDuplicateModal = true;
+ }
+
+ async function confirmDelete() {
+ if (!selectedInstance) return;
+ await instancesState.deleteInstance(selectedInstance.id);
+ showDeleteConfirm = false;
+ selectedInstance = null;
+ }
+
+ async function confirmDuplicate() {
+ if (!selectedInstance || !duplicateName.trim()) return;
+ await instancesState.duplicateInstance(selectedInstance.id, duplicateName.trim());
+ showDuplicateModal = false;
+ selectedInstance = null;
+ duplicateName = "";
+ }
+
+ function formatDate(timestamp: number): string {
+ return new Date(timestamp * 1000).toLocaleDateString();
+ }
+
+ function formatLastPlayed(timestamp: number): string {
+ const date = new Date(timestamp * 1000);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) return "Today";
+ if (days === 1) return "Yesterday";
+ if (days < 7) return `${days} days ago`;
+ return date.toLocaleDateString();
+ }
+</script>
+
+<div class="h-full flex flex-col gap-4 p-6 overflow-y-auto">
+ <div class="flex items-center justify-between">
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">Instances</h1>
+ <button
+ onclick={handleCreate}
+ class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
+ >
+ <Plus size={18} />
+ Create Instance
+ </button>
+ </div>
+
+ {#if instancesState.instances.length === 0}
+ <div class="flex-1 flex items-center justify-center">
+ <div class="text-center text-gray-500 dark:text-gray-400">
+ <p class="text-lg mb-2">No instances yet</p>
+ <p class="text-sm">Create your first instance to get started</p>
+ </div>
+ </div>
+ {:else}
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ {#each instancesState.instances as instance (instance.id)}
+ <div
+ role="button"
+ tabindex="0"
+ class="relative p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 transition-all cursor-pointer hover:border-blue-500 {instancesState.activeInstanceId === instance.id
+ ? 'border-blue-500'
+ : 'border-transparent'}"
+ onclick={() => instancesState.setActiveInstance(instance.id)}
+ onkeydown={(e) => e.key === "Enter" && instancesState.setActiveInstance(instance.id)}
+ >
+ {#if instancesState.activeInstanceId === instance.id}
+ <div class="absolute top-2 right-2">
+ <div class="w-3 h-3 bg-blue-500 rounded-full"></div>
+ </div>
+ {/if}
+
+ <div class="flex items-start justify-between mb-2">
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
+ {instance.name}
+ </h3>
+ <div class="flex gap-1">
+ <button
+ type="button"
+ onclick={(e) => {
+ e.stopPropagation();
+ handleEdit(instance);
+ }}
+ class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
+ title="Edit"
+ >
+ <Edit2 size={16} class="text-gray-600 dark:text-gray-400" />
+ </button>
+ <button
+ type="button"
+ onclick={(e) => {
+ e.stopPropagation();
+ handleDuplicate(instance);
+ }}
+ class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
+ title="Duplicate"
+ >
+ <Copy size={16} class="text-gray-600 dark:text-gray-400" />
+ </button>
+ <button
+ type="button"
+ onclick={(e) => {
+ e.stopPropagation();
+ handleDelete(instance);
+ }}
+ class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
+ title="Delete"
+ >
+ <Trash2 size={16} class="text-red-600 dark:text-red-400" />
+ </button>
+ </div>
+ </div>
+
+ <div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
+ {#if instance.version_id}
+ <p>Version: <span class="font-medium">{instance.version_id}</span></p>
+ {:else}
+ <p class="text-gray-400">No version selected</p>
+ {/if}
+
+ {#if instance.mod_loader && instance.mod_loader !== "vanilla"}
+ <p>
+ Mod Loader: <span class="font-medium capitalize">{instance.mod_loader}</span>
+ {#if instance.mod_loader_version}
+ <span class="text-gray-500">({instance.mod_loader_version})</span>
+ {/if}
+ </p>
+ {/if}
+
+ <p>Created: {formatDate(instance.created_at)}</p>
+
+ {#if instance.last_played}
+ <p>Last played: {formatLastPlayed(instance.last_played)}</p>
+ {/if}
+ </div>
+
+ {#if instance.notes}
+ <p class="mt-2 text-sm text-gray-500 dark:text-gray-500 italic">
+ {instance.notes}
+ </p>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+</div>
+
+<!-- Create Modal -->
+<InstanceCreationModal isOpen={showCreateModal} onClose={() => (showCreateModal = false)} />
+
+<!-- Instance Editor Modal -->
+<InstanceEditorModal
+ isOpen={editingInstance !== null}
+ instance={editingInstance}
+ onClose={() => {
+ editingInstance = null;
+ }}
+/>
+
+<!-- Delete Confirmation -->
+{#if showDeleteConfirm && selectedInstance}
+ <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
+ <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96">
+ <h2 class="text-xl font-bold mb-4 text-red-600 dark:text-red-400">Delete Instance</h2>
+ <p class="mb-4 text-gray-700 dark:text-gray-300">
+ Are you sure you want to delete "{selectedInstance.name}"? This action cannot be undone and will delete all game data for this instance.
+ </p>
+ <div class="flex gap-2 justify-end">
+ <button
+ onclick={() => {
+ showDeleteConfirm = false;
+ selectedInstance = null;
+ }}
+ class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={confirmDelete}
+ class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
+ >
+ Delete
+ </button>
+ </div>
+ </div>
+ </div>
+{/if}
+
+<!-- Duplicate Modal -->
+{#if showDuplicateModal && selectedInstance}
+ <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
+ <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96">
+ <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Duplicate Instance</h2>
+ <input
+ type="text"
+ bind:value={duplicateName}
+ placeholder="New instance name"
+ class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4"
+ onkeydown={(e) => e.key === "Enter" && confirmDuplicate()}
+ />
+ <div class="flex gap-2 justify-end">
+ <button
+ onclick={() => {
+ showDuplicateModal = false;
+ selectedInstance = null;
+ duplicateName = "";
+ }}
+ class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={confirmDuplicate}
+ disabled={!duplicateName.trim()}
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+ >
+ Duplicate
+ </button>
+ </div>
+ </div>
+ </div>
+{/if}
diff --git a/packages/ui/src/components/LoginModal.svelte b/packages/ui/src/components/LoginModal.svelte
new file mode 100644
index 0000000..1886cd9
--- /dev/null
+++ b/packages/ui/src/components/LoginModal.svelte
@@ -0,0 +1,126 @@
+<script lang="ts">
+ import { open } from "@tauri-apps/plugin-shell";
+ import { authState } from "../stores/auth.svelte";
+
+ function openLink(url: string) {
+ open(url);
+ }
+</script>
+
+{#if authState.isLoginModalOpen}
+ <div
+ 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-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-gray-900 dark:text-white">Login</h2>
+ <button
+ onclick={() => authState.closeLoginModal()}
+ class="text-zinc-500 hover:text-black dark:hover:text-white transition group"
+ >
+ ✕
+ </button>
+ </div>
+
+ {#if authState.loginMode === "select"}
+ <div class="space-y-4">
+ <button
+ onclick={() => authState.startMicrosoftLogin()}
+ 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
+ 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-200 dark:border-zinc-700"></div>
+ </div>
+ <div class="relative flex justify-center text-xs uppercase">
+ <span class="bg-white dark:bg-zinc-900 px-2 text-zinc-400 dark:text-zinc-500">OR</span>
+ </div>
+ </div>
+
+ <div class="space-y-2">
+ <input
+ type="text"
+ bind:value={authState.offlineUsername}
+ placeholder="Offline Username"
+ 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-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>
+ </div>
+ </div>
+ {:else if authState.loginMode === "microsoft"}
+ <div class="text-center">
+ {#if authState.msLoginLoading && !authState.deviceCodeData}
+ <div class="py-8 text-zinc-400 animate-pulse">
+ Starting login flow...
+ </div>
+ {:else if authState.deviceCodeData}
+ <div class="space-y-4">
+ <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-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-gray-500 dark:text-zinc-400 mt-2">2. Enter this code:</p>
+ <div
+ 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 || "")}
+ onclick={() =>
+ navigator.clipboard.writeText(
+ authState.deviceCodeData?.user_code || ""
+ )}
+ >
+ {authState.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-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>
+
+ <button
+ onclick={() => { authState.stopPolling(); authState.loginMode = "select"; }}
+ class="text-xs text-zinc-500 hover:text-zinc-300 mt-6 underline"
+ >Cancel</button
+ >
+ </div>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ </div>
+{/if}
diff --git a/packages/ui/src/components/ModLoaderSelector.svelte b/packages/ui/src/components/ModLoaderSelector.svelte
new file mode 100644
index 0000000..50caa8c
--- /dev/null
+++ b/packages/ui/src/components/ModLoaderSelector.svelte
@@ -0,0 +1,455 @@
+<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
+ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+ import type {
+ FabricGameVersion,
+ FabricLoaderVersion,
+ ForgeVersion,
+ ModLoaderType,
+ } from "../types";
+ import { Loader2, Download, AlertCircle, Check, ChevronDown, CheckCircle } from 'lucide-svelte';
+ import { logsState } from "../stores/logs.svelte";
+ import { instancesState } from "../stores/instances.svelte";
+
+ interface Props {
+ selectedGameVersion: string;
+ onInstall: (versionId: string) => void;
+ }
+
+ let { selectedGameVersion, onInstall }: Props = $props();
+
+ // State
+ let selectedLoader = $state<ModLoaderType>("vanilla");
+ let isLoading = $state(false);
+ let isInstalling = $state(false);
+ let error = $state<string | null>(null);
+ let isVersionInstalled = $state(false);
+
+ // Fabric state
+ let fabricLoaders = $state<FabricLoaderVersion[]>([]);
+ let selectedFabricLoader = $state("");
+ let isFabricDropdownOpen = $state(false);
+
+ // Forge state
+ let forgeVersions = $state<ForgeVersion[]>([]);
+ let selectedForgeVersion = $state("");
+ let isForgeDropdownOpen = $state(false);
+
+ let fabricDropdownRef = $state<HTMLDivElement | null>(null);
+ let forgeDropdownRef = $state<HTMLDivElement | null>(null);
+
+ // Check if version is installed when game version changes
+ $effect(() => {
+ if (selectedGameVersion) {
+ checkInstallStatus();
+ }
+ });
+
+ // Load mod loader versions when game version or loader type changes
+ $effect(() => {
+ if (selectedGameVersion && selectedLoader !== "vanilla") {
+ loadModLoaderVersions();
+ }
+ });
+
+ async function checkInstallStatus() {
+ if (!selectedGameVersion || !instancesState.activeInstanceId) {
+ isVersionInstalled = false;
+ return;
+ }
+ try {
+ isVersionInstalled = await invoke<boolean>("check_version_installed", {
+ instanceId: instancesState.activeInstanceId,
+ versionId: selectedGameVersion,
+ });
+ } catch (e) {
+ console.error("Failed to check install status:", e);
+ isVersionInstalled = false;
+ }
+ }
+
+ 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) {
+ 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) {
+ 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 installVanilla() {
+ if (!selectedGameVersion) {
+ error = "Please select a Minecraft version first";
+ return;
+ }
+
+ isInstalling = true;
+ error = null;
+ logsState.addLog("info", "Installer", `Starting installation of ${selectedGameVersion}...`);
+
+ if (!instancesState.activeInstanceId) {
+ error = "Please select an instance first";
+ return;
+ }
+ try {
+ await invoke("install_version", {
+ instanceId: instancesState.activeInstanceId,
+ versionId: selectedGameVersion,
+ });
+ logsState.addLog("info", "Installer", `Successfully installed ${selectedGameVersion}`);
+ isVersionInstalled = true;
+ onInstall(selectedGameVersion);
+ } catch (e) {
+ error = `Failed to install: ${e}`;
+ logsState.addLog("error", "Installer", `Installation failed: ${e}`);
+ console.error(e);
+ } finally {
+ isInstalling = false;
+ }
+ }
+
+ async function installModLoader() {
+ if (!selectedGameVersion) {
+ error = "Please select a Minecraft version first";
+ return;
+ }
+
+ if (!instancesState.activeInstanceId) {
+ error = "Please select an instance first";
+ isInstalling = false;
+ return;
+ }
+
+ isInstalling = true;
+ error = null;
+
+ try {
+ // First install the base game if not installed
+ if (!isVersionInstalled) {
+ logsState.addLog("info", "Installer", `Installing base game ${selectedGameVersion} first...`);
+ await invoke("install_version", {
+ instanceId: instancesState.activeInstanceId,
+ versionId: selectedGameVersion,
+ });
+ isVersionInstalled = true;
+ }
+
+ // Then install the mod loader
+ if (selectedLoader === "fabric" && selectedFabricLoader) {
+ logsState.addLog("info", "Installer", `Installing Fabric ${selectedFabricLoader} for ${selectedGameVersion}...`);
+ const result = await invoke<any>("install_fabric", {
+ instanceId: instancesState.activeInstanceId,
+ gameVersion: selectedGameVersion,
+ loaderVersion: selectedFabricLoader,
+ });
+ logsState.addLog("info", "Installer", `Fabric installed successfully: ${result.id}`);
+ onInstall(result.id);
+ } else if (selectedLoader === "forge" && selectedForgeVersion) {
+ logsState.addLog("info", "Installer", `Installing Forge ${selectedForgeVersion} for ${selectedGameVersion}...`);
+ const result = await invoke<any>("install_forge", {
+ instanceId: instancesState.activeInstanceId,
+ gameVersion: selectedGameVersion,
+ forgeVersion: selectedForgeVersion,
+ });
+ logsState.addLog("info", "Installer", `Forge installed successfully: ${result.id}`);
+ onInstall(result.id);
+ }
+ } catch (e) {
+ error = `Failed to install ${selectedLoader}: ${e}`;
+ logsState.addLog("error", "Installer", `Installation failed: ${e}`);
+ console.error(e);
+ } finally {
+ isInstalling = false;
+ }
+ }
+
+ function onLoaderChange(loader: ModLoaderType) {
+ selectedLoader = loader;
+ error = null;
+ if (loader !== "vanilla" && selectedGameVersion) {
+ loadModLoaderVersions();
+ }
+ }
+
+ function handleFabricClickOutside(e: MouseEvent) {
+ if (fabricDropdownRef && !fabricDropdownRef.contains(e.target as Node)) {
+ isFabricDropdownOpen = false;
+ }
+ }
+
+ function handleForgeClickOutside(e: MouseEvent) {
+ if (forgeDropdownRef && !forgeDropdownRef.contains(e.target as Node)) {
+ isForgeDropdownOpen = false;
+ }
+ }
+
+ $effect(() => {
+ if (isFabricDropdownOpen) {
+ document.addEventListener('click', handleFabricClickOutside);
+ return () => document.removeEventListener('click', handleFabricClickOutside);
+ }
+ });
+
+ $effect(() => {
+ if (isForgeDropdownOpen) {
+ document.addEventListener('click', handleForgeClickOutside);
+ return () => document.removeEventListener('click', handleForgeClickOutside);
+ }
+ });
+
+ let selectedFabricLabel = $derived(
+ fabricLoaders.find(l => l.version === selectedFabricLoader)
+ ? `${selectedFabricLoader}${fabricLoaders.find(l => l.version === selectedFabricLoader)?.stable ? ' (stable)' : ''}`
+ : selectedFabricLoader || 'Select version'
+ );
+
+ let selectedForgeLabel = $derived(
+ forgeVersions.find(v => v.version === selectedForgeVersion)
+ ? `${selectedForgeVersion}${forgeVersions.find(v => v.version === selectedForgeVersion)?.recommended ? ' (Recommended)' : ''}`
+ : selectedForgeVersion || 'Select version'
+ );
+</script>
+
+<div class="space-y-4">
+ <div class="flex items-center justify-between">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-zinc-500">Loader Type</h3>
+ </div>
+
+ <!-- Loader Type Tabs - Simple Clean Style -->
+ <div class="flex p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-sm border border-zinc-200 dark:border-white/5">
+ {#each ['vanilla', 'fabric', 'forge'] as loader}
+ <button
+ class="flex-1 px-3 py-2 rounded-sm text-sm font-medium transition-all duration-200 capitalize
+ {selectedLoader === loader
+ ? 'bg-white dark:bg-white/10 text-black dark:text-white shadow-sm'
+ : 'text-zinc-500 dark:text-zinc-500 hover:text-black dark:hover:text-white'}"
+ onclick={() => onLoaderChange(loader as ModLoaderType)}
+ disabled={isInstalling}
+ >
+ {loader}
+ </button>
+ {/each}
+ </div>
+
+ <!-- Content Area -->
+ <div class="min-h-[100px] flex flex-col justify-center">
+ {#if !selectedGameVersion}
+ <div class="flex items-center gap-3 p-4 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 text-amber-700 dark:text-amber-200 rounded-sm text-sm">
+ <AlertCircle size={16} />
+ <span>Please select a Minecraft version first.</span>
+ </div>
+
+ {:else if selectedLoader === "vanilla"}
+ <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
+ <div class="text-center p-4 border border-dashed border-zinc-200 dark:border-white/10 rounded-sm text-zinc-500 text-sm">
+ Standard Minecraft experience. No modifications.
+ </div>
+
+ {#if isVersionInstalled}
+ <div class="flex items-center justify-center gap-2 p-3 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 text-emerald-700 dark:text-emerald-300 rounded-sm text-sm">
+ <CheckCircle size={16} />
+ <span>Version {selectedGameVersion} is installed</span>
+ </div>
+ {:else}
+ <button
+ class="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
+ onclick={installVanilla}
+ disabled={isInstalling}
+ >
+ {#if isInstalling}
+ <Loader2 class="animate-spin" size={16} />
+ Installing...
+ {:else}
+ <Download size={16} />
+ Install {selectedGameVersion}
+ {/if}
+ </button>
+ {/if}
+ </div>
+
+ {:else if isLoading}
+ <div class="flex flex-col items-center gap-3 text-sm text-zinc-500 py-6">
+ <Loader2 class="animate-spin" size={20} />
+ <span>Fetching {selectedLoader} manifest...</span>
+ </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-sm 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">
+ {#if fabricLoaders.length === 0}
+ <div class="text-center p-4 text-sm text-zinc-500 italic">
+ No Fabric versions available for {selectedGameVersion}
+ </div>
+ {:else}
+ <div>
+ <label for="fabric-loader-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2"
+ >Loader Version</label
+ >
+ <!-- Custom Fabric Dropdown -->
+ <div class="relative" bind:this={fabricDropdownRef}>
+ <button
+ type="button"
+ onclick={() => isFabricDropdownOpen = !isFabricDropdownOpen}
+ disabled={isInstalling}
+ class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left
+ bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md
+ text-sm text-gray-900 dark:text-white
+ hover:border-zinc-400 dark:hover:border-zinc-600
+ focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+ transition-colors cursor-pointer outline-none disabled:opacity-50"
+ >
+ <span class="truncate">{selectedFabricLabel}</span>
+ <ChevronDown
+ size={14}
+ class="shrink-0 text-zinc-400 dark:text-zinc-500 transition-transform duration-200 {isFabricDropdownOpen ? 'rotate-180' : ''}"
+ />
+ </button>
+
+ {#if isFabricDropdownOpen}
+ <div
+ class="absolute z-50 w-full mt-1 py-1 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md shadow-xl
+ max-h-48 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
+ >
+ {#each fabricLoaders as loader}
+ <button
+ type="button"
+ onclick={() => { selectedFabricLoader = loader.version; isFabricDropdownOpen = false; }}
+ class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
+ transition-colors outline-none cursor-pointer
+ {loader.version === selectedFabricLoader
+ ? 'bg-indigo-600 text-white'
+ : 'text-gray-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'}"
+ >
+ <span class="truncate">{loader.version} {loader.stable ? "(stable)" : ""}</span>
+ {#if loader.version === selectedFabricLoader}
+ <Check size={14} class="shrink-0 ml-2" />
+ {/if}
+ </button>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <button
+ class="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
+ onclick={installModLoader}
+ disabled={isInstalling || !selectedFabricLoader}
+ >
+ {#if isInstalling}
+ <Loader2 class="animate-spin" size={16} />
+ Installing...
+ {:else}
+ <Download size={16} />
+ Install Fabric {selectedFabricLoader}
+ {/if}
+ </button>
+ {/if}
+ </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-zinc-500 italic">
+ No Forge versions available for {selectedGameVersion}
+ </div>
+ {:else}
+ <div>
+ <label for="forge-version-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2"
+ >Forge Version</label
+ >
+ <!-- Custom Forge Dropdown -->
+ <div class="relative" bind:this={forgeDropdownRef}>
+ <button
+ type="button"
+ onclick={() => isForgeDropdownOpen = !isForgeDropdownOpen}
+ disabled={isInstalling}
+ class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left
+ bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md
+ text-sm text-gray-900 dark:text-white
+ hover:border-zinc-400 dark:hover:border-zinc-600
+ focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+ transition-colors cursor-pointer outline-none disabled:opacity-50"
+ >
+ <span class="truncate">{selectedForgeLabel}</span>
+ <ChevronDown
+ size={14}
+ class="shrink-0 text-zinc-400 dark:text-zinc-500 transition-transform duration-200 {isForgeDropdownOpen ? 'rotate-180' : ''}"
+ />
+ </button>
+
+ {#if isForgeDropdownOpen}
+ <div
+ class="absolute z-50 w-full mt-1 py-1 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md shadow-xl
+ max-h-48 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
+ >
+ {#each forgeVersions as version}
+ <button
+ type="button"
+ onclick={() => { selectedForgeVersion = version.version; isForgeDropdownOpen = false; }}
+ class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
+ transition-colors outline-none cursor-pointer
+ {version.version === selectedForgeVersion
+ ? 'bg-indigo-600 text-white'
+ : 'text-gray-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'}"
+ >
+ <span class="truncate">{version.version} {version.recommended ? "(Recommended)" : ""}</span>
+ {#if version.version === selectedForgeVersion}
+ <Check size={14} class="shrink-0 ml-2" />
+ {/if}
+ </button>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <button
+ class="w-full bg-orange-600 hover:bg-orange-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
+ onclick={installModLoader}
+ disabled={isInstalling || !selectedForgeVersion}
+ >
+ {#if isInstalling}
+ <Loader2 class="animate-spin" size={16} />
+ Installing...
+ {:else}
+ <Download size={16} />
+ Install Forge {selectedForgeVersion}
+ {/if}
+ </button>
+ {/if}
+ </div>
+ {/if}
+ </div>
+</div>
diff --git a/packages/ui/src/components/ParticleBackground.svelte b/packages/ui/src/components/ParticleBackground.svelte
new file mode 100644
index 0000000..7644b1a
--- /dev/null
+++ b/packages/ui/src/components/ParticleBackground.svelte
@@ -0,0 +1,70 @@
+<script lang="ts" module>
+ import { SaturnEffect } from "../lib/effects/SaturnEffect";
+
+ // Global reference to the active Saturn effect for external control
+ let globalSaturnEffect: SaturnEffect | null = null;
+
+ export function getSaturnEffect(): SaturnEffect | null {
+ return globalSaturnEffect;
+ }
+</script>
+
+<script lang="ts">
+ import { onMount, onDestroy } from "svelte";
+ import { ConstellationEffect } from "../lib/effects/ConstellationEffect";
+ 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);
+ globalSaturnEffect = activeEffect;
+ } else {
+ activeEffect = new ConstellationEffect(canvas);
+ globalSaturnEffect = null;
+ }
+
+ // 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();
+ globalSaturnEffect = null;
+ });
+</script>
+
+<canvas
+ bind:this={canvas}
+ class="absolute inset-0 z-0 pointer-events-none"
+></canvas>
diff --git a/packages/ui/src/components/SettingsView.svelte b/packages/ui/src/components/SettingsView.svelte
new file mode 100644
index 0000000..0020506
--- /dev/null
+++ b/packages/ui/src/components/SettingsView.svelte
@@ -0,0 +1,1217 @@
+<script lang="ts">
+ import { open } from "@tauri-apps/plugin-dialog";
+ import { settingsState } from "../stores/settings.svelte";
+ import CustomSelect from "./CustomSelect.svelte";
+ import ConfigEditorModal from "./ConfigEditorModal.svelte";
+ import { onMount } from "svelte";
+ import { RefreshCw, FileJson } from "lucide-svelte";
+
+ // Use convertFileSrc directly from settingsState.backgroundUrl for cleaner approach
+ // or use the imported one if passing raw path.
+ import { convertFileSrc } from "@tauri-apps/api/core";
+
+ const effectOptions = [
+ { value: "saturn", label: "Saturn" },
+ { value: "constellation", label: "Network (Constellation)" }
+ ];
+
+ const logServiceOptions = [
+ { value: "paste.rs", label: "paste.rs (Free, No Account)" },
+ { value: "pastebin.com", label: "pastebin.com (Requires API Key)" }
+ ];
+
+ const llmProviderOptions = [
+ { value: "ollama", label: "Ollama (Local)" },
+ { value: "openai", label: "OpenAI (Remote)" }
+ ];
+
+ const languageOptions = [
+ { value: "auto", label: "Auto (Match User)" },
+ { value: "English", label: "English" },
+ { value: "Chinese", label: "中文" },
+ { value: "Japanese", label: "日本語" },
+ { value: "Korean", label: "한국어" },
+ { value: "Spanish", label: "Español" },
+ { value: "French", label: "Français" },
+ { value: "German", label: "Deutsch" },
+ { value: "Russian", label: "Русский" },
+ ];
+
+ const ttsProviderOptions = [
+ { value: "disabled", label: "Disabled" },
+ { value: "piper", label: "Piper TTS (Local)" },
+ { value: "edge", label: "Edge TTS (Online)" },
+ ];
+
+ const personas = [
+ {
+ value: "default",
+ label: "Minecraft Expert (Default)",
+ prompt: "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice."
+ },
+ {
+ value: "technical",
+ label: "Technical Debugger",
+ prompt: "You are a technical support specialist for Minecraft. Focus strictly on analyzing logs, identifying crash causes, and providing technical solutions. Be precise and avoid conversational filler."
+ },
+ {
+ value: "concise",
+ label: "Concise Helper",
+ prompt: "You are a direct and concise assistant. Provide answers in as few words as possible while remaining accurate. Use bullet points for lists."
+ },
+ {
+ value: "explain",
+ label: "Teacher / Explainer",
+ prompt: "You are a patient teacher. Explain Minecraft concepts, redstone mechanics, and mod features in simple, easy-to-understand terms suitable for beginners."
+ },
+ {
+ value: "pirate",
+ label: "Pirate Captain",
+ prompt: "You are a salty Minecraft Pirate Captain! Yarr! Speak like a pirate while helping the crew (the user) with their blocky adventures. Use terms like 'matey', 'landlubber', and 'treasure'."
+ }
+ ];
+
+ let selectedPersona = $state("");
+
+ function applyPersona(value: string) {
+ const persona = personas.find(p => p.value === value);
+ if (persona) {
+ settingsState.settings.assistant.system_prompt = persona.prompt;
+ selectedPersona = value; // Keep selected to show what's active
+ }
+ }
+
+ function resetSystemPrompt() {
+ const defaultPersona = personas.find(p => p.value === "default");
+ if (defaultPersona) {
+ settingsState.settings.assistant.system_prompt = defaultPersona.prompt;
+ selectedPersona = "default";
+ }
+ }
+
+ // Load models when assistant settings are shown
+ function loadModelsForProvider() {
+ if (settingsState.settings.assistant.llm_provider === "ollama") {
+ settingsState.loadOllamaModels();
+ } else if (settingsState.settings.assistant.llm_provider === "openai") {
+ settingsState.loadOpenaiModels();
+ }
+ }
+
+ 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();
+ }
+
+ let migrating = $state(false);
+ async function runMigrationToSharedCaches() {
+ if (migrating) return;
+ migrating = true;
+ try {
+ const { invoke } = await import("@tauri-apps/api/core");
+ const result = await invoke<{
+ moved_files: number;
+ hardlinks: number;
+ copies: number;
+ saved_mb: number;
+ }>("migrate_shared_caches");
+
+ // Reload settings to reflect changes
+ await settingsState.loadSettings();
+
+ // Show success message
+ const msg = `Migration complete! ${result.moved_files} files (${result.hardlinks} hardlinks, ${result.copies} copies), ${result.saved_mb.toFixed(2)} MB saved.`;
+ console.log(msg);
+ alert(msg);
+ } catch (e) {
+ console.error("Migration failed:", e);
+ alert(`Migration failed: ${e}`);
+ } finally {
+ migrating = false;
+ }
+ }
+</script>
+
+<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>
+
+ <button
+ onclick={() => settingsState.openConfigEditor()}
+ class="p-2 hover:bg-white/10 rounded-lg text-zinc-400 hover:text-white transition-colors flex items-center gap-2 text-sm border border-transparent hover:border-white/5"
+ title="Open Settings JSON"
+ >
+ <FileJson size={18} />
+ <span class="hidden sm:inline">Open JSON</span>
+ </button>
+ </div>
+
+ <div class="flex-1 overflow-y-auto pr-2 space-y-6 custom-scrollbar pb-10">
+
+ <!-- Appearance / Background -->
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-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"
+ onerror={(e) => {
+ console.error("Failed to load image:", settingsState.settings.custom_background_path, e);
+ // e.currentTarget.style.display = 'none';
+ }}
+ />
+ {: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>
+ <CustomSelect
+ options={effectOptions}
+ bind:value={settingsState.settings.active_effect}
+ onchange={() => settingsState.saveSettings()}
+ class="w-52"
+ />
+ </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-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-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>
+ <button
+ onclick={() => settingsState.openJavaDownloadModal()}
+ class="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-xl transition-colors whitespace-nowrap text-sm font-medium"
+ >
+ Download Java
+ </button>
+ </div>
+ </div>
+
+ {#if settingsState.javaInstallations.length > 0}
+ <div class="mt-4 space-y-2">
+ <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 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-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-300 text-[10px] font-bold uppercase tracking-wider">Selected</span>
+ {/if}
+ </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}
+ </div>
+ </div>
+
+ <!-- Memory -->
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <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-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-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-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-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="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <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-sm font-medium text-white/70 mb-2">Width</label>
+ <input
+ id="window-width"
+ bind:value={settingsState.settings.width}
+ type="number"
+ 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-sm font-medium text-white/70 mb-2">Height</label>
+ <input
+ id="window-height"
+ bind:value={settingsState.settings.height}
+ type="number"
+ 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>
+
+ <!-- Download Settings -->
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <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>
+
+ <!-- Storage & Caches -->
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">Storage & Version Caches</h3>
+ <div class="space-y-4">
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium text-white/90" id="shared-caches-label">Use Shared Caches</h4>
+ <p class="text-xs text-white/40 mt-1">Store versions/libraries/assets in a global cache shared by all instances.</p>
+ </div>
+ <button
+ aria-labelledby="shared-caches-label"
+ onclick={() => { settingsState.settings.use_shared_caches = !settingsState.settings.use_shared_caches; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.use_shared_caches ? 'bg-indigo-500' : 'bg-white/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.use_shared_caches ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium text-white/90" id="legacy-storage-label">Keep Legacy Per-Instance Storage</h4>
+ <p class="text-xs text-white/40 mt-1">Do not migrate existing instance caches; keep current layout.</p>
+ </div>
+ <button
+ aria-labelledby="legacy-storage-label"
+ onclick={() => { settingsState.settings.keep_legacy_per_instance_storage = !settingsState.settings.keep_legacy_per_instance_storage; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.keep_legacy_per_instance_storage ? 'bg-indigo-500' : 'bg-white/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.keep_legacy_per_instance_storage ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ <div class="flex items-center justify-between pt-2 border-t border-white/10">
+ <div>
+ <h4 class="text-sm font-medium text-white/90">Run Migration</h4>
+ <p class="text-xs text-white/40 mt-1">Hard-link or copy existing per-instance caches into the shared cache.</p>
+ </div>
+ <button
+ onclick={runMigrationToSharedCaches}
+ disabled={migrating}
+ class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {migrating ? "Migrating..." : "Migrate Now"}
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Feature Flags -->
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">Feature Flags (Launcher Arguments)</h3>
+ <div class="space-y-4">
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium text-white/90" id="demo-user-label">Demo User</h4>
+ <p class="text-xs text-white/40 mt-1">Enable demo-related arguments when rules require them.</p>
+ </div>
+ <button
+ aria-labelledby="demo-user-label"
+ onclick={() => { settingsState.settings.feature_flags.demo_user = !settingsState.settings.feature_flags.demo_user; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.demo_user ? 'bg-indigo-500' : 'bg-white/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.feature_flags.demo_user ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium text-white/90" id="quick-play-label">Quick Play</h4>
+ <p class="text-xs text-white/40 mt-1">Enable quick play singleplayer/multiplayer arguments.</p>
+ </div>
+ <button
+ aria-labelledby="quick-play-label"
+ onclick={() => { settingsState.settings.feature_flags.quick_play_enabled = !settingsState.settings.feature_flags.quick_play_enabled; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.quick_play_enabled ? 'bg-indigo-500' : 'bg-white/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.feature_flags.quick_play_enabled ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ {#if settingsState.settings.feature_flags.quick_play_enabled}
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-2 border-l-2 border-white/10">
+ <div>
+ <label class="block text-sm font-medium text-white/70 mb-2">Singleplayer World Path</label>
+ <input
+ type="text"
+ bind:value={settingsState.settings.feature_flags.quick_play_path}
+ placeholder="/path/to/saves/MyWorld"
+ 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 font-mono text-xs transition-colors"
+ />
+ </div>
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium text-white/90" id="qp-singleplayer-label">Prefer Singleplayer</h4>
+ <p class="text-xs text-white/40 mt-1">If enabled, use singleplayer quick play path.</p>
+ </div>
+ <button
+ aria-labelledby="qp-singleplayer-label"
+ onclick={() => { settingsState.settings.feature_flags.quick_play_singleplayer = !settingsState.settings.feature_flags.quick_play_singleplayer; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.quick_play_singleplayer ? 'bg-indigo-500' : 'bg-white/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.feature_flags.quick_play_singleplayer ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+ <div>
+ <label class="block text-sm font-medium text-white/70 mb-2">Multiplayer Server Address</label>
+ <input
+ type="text"
+ bind:value={settingsState.settings.feature_flags.quick_play_multiplayer_server}
+ placeholder="example.org:25565"
+ 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 font-mono text-xs transition-colors"
+ />
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <!-- Debug / Logs -->
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ Debug & Logs
+ </h3>
+ <div class="space-y-4">
+ <div>
+ <label for="log-service" class="block text-sm font-medium text-white/70 mb-2">Log Upload Service</label>
+ <CustomSelect
+ options={logServiceOptions}
+ bind:value={settingsState.settings.log_upload_service}
+ class="w-full"
+ />
+ </div>
+
+ {#if settingsState.settings.log_upload_service === 'pastebin.com'}
+ <div>
+ <label for="pastebin-key" class="block text-sm font-medium text-white/70 mb-2">Pastebin Dev API Key</label>
+ <input
+ id="pastebin-key"
+ type="password"
+ bind:value={settingsState.settings.pastebin_api_key}
+ placeholder="Enter your API Key"
+ class="dark:bg-zinc-900 bg-white dark:text-white text-black w-full px-4 py-3 rounded-xl border dark:border-zinc-700 border-gray-300 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 outline-none transition-colors placeholder:text-zinc-500"
+ />
+ <p class="text-xs text-white/30 mt-2">
+ Get your API key from <a href="https://pastebin.com/doc_api#1" target="_blank" class="text-indigo-400 hover:underline">Pastebin API Documentation</a>.
+ </p>
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <!-- AI Assistant -->
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <rect x="3" y="11" width="18" height="10" rx="2"/>
+ <circle cx="12" cy="5" r="2"/>
+ <path d="M12 7v4"/>
+ <circle cx="8" cy="16" r="1" fill="currentColor"/>
+ <circle cx="16" cy="16" r="1" fill="currentColor"/>
+ </svg>
+ AI Assistant
+ </h3>
+ <div class="space-y-6">
+ <!-- Enable/Disable -->
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="assistant-enabled-label">Enable Assistant</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Toggle the AI assistant feature on or off.</p>
+ </div>
+ <button
+ aria-labelledby="assistant-enabled-label"
+ onclick={() => { settingsState.settings.assistant.enabled = !settingsState.settings.assistant.enabled; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.assistant.enabled ? '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.assistant.enabled ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ {#if settingsState.settings.assistant.enabled}
+ <!-- LLM Provider Section -->
+ <div class="pt-4 border-t dark:border-white/5 border-black/5">
+ <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Language Model</h4>
+
+ <div class="space-y-4">
+ <div>
+ <label for="llm-provider" class="block text-sm font-medium text-white/70 mb-2">Provider</label>
+ <CustomSelect
+ options={llmProviderOptions}
+ bind:value={settingsState.settings.assistant.llm_provider}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ />
+ </div>
+
+ {#if settingsState.settings.assistant.llm_provider === 'ollama'}
+ <!-- Ollama Settings -->
+ <div class="pl-4 border-l-2 border-indigo-500/30 space-y-4">
+ <div>
+ <label for="ollama-endpoint" class="block text-sm font-medium text-white/70 mb-2">API Endpoint</label>
+ <div class="flex gap-2">
+ <input
+ id="ollama-endpoint"
+ type="text"
+ bind:value={settingsState.settings.assistant.ollama_endpoint}
+ placeholder="http://localhost:11434"
+ 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"
+ />
+ <button
+ onclick={() => settingsState.loadOllamaModels()}
+ disabled={settingsState.isLoadingOllamaModels}
+ class="px-4 py-2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium flex items-center gap-2"
+ title="Refresh models"
+ >
+ <RefreshCw size={14} class={settingsState.isLoadingOllamaModels ? "animate-spin" : ""} />
+ <span class="hidden sm:inline">Refresh</span>
+ </button>
+ </div>
+ <p class="text-xs text-white/30 mt-2">
+ Default: http://localhost:11434. Make sure Ollama is running.
+ </p>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between mb-2">
+ <label for="ollama-model" class="block text-sm font-medium text-white/70">Model</label>
+ {#if settingsState.ollamaModels.length > 0}
+ <span class="text-[10px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
+ {settingsState.ollamaModels.length} installed
+ </span>
+ {/if}
+ </div>
+
+ {#if settingsState.isLoadingOllamaModels}
+ <div class="bg-black/40 text-white/50 w-full px-4 py-3 rounded-xl border border-white/10 text-sm flex items-center gap-2">
+ <RefreshCw size={14} class="animate-spin" />
+ Loading models...
+ </div>
+ {:else if settingsState.ollamaModelsError}
+ <div class="bg-red-500/10 text-red-400 w-full px-4 py-3 rounded-xl border border-red-500/20 text-sm">
+ {settingsState.ollamaModelsError}
+ </div>
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.ollama_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full mt-2"
+ allowCustom={true}
+ />
+ {:else if settingsState.ollamaModels.length === 0}
+ <div class="bg-amber-500/10 text-amber-400 w-full px-4 py-3 rounded-xl border border-amber-500/20 text-sm">
+ No models found. Click Refresh to load installed models.
+ </div>
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.ollama_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full mt-2"
+ allowCustom={true}
+ />
+ {:else}
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.ollama_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ allowCustom={true}
+ />
+ {/if}
+
+ <p class="text-xs text-white/30 mt-2">
+ Run <code class="bg-black/30 px-1 rounded">ollama pull {'<model>'}</code> to download new models. Or type a custom model name above.
+ </p>
+ </div>
+ </div>
+ {:else if settingsState.settings.assistant.llm_provider === 'openai'}
+ <!-- OpenAI Settings -->
+ <div class="pl-4 border-l-2 border-emerald-500/30 space-y-4">
+ <div>
+ <label for="openai-key" class="block text-sm font-medium text-white/70 mb-2">API Key</label>
+ <div class="flex gap-2">
+ <input
+ id="openai-key"
+ type="password"
+ bind:value={settingsState.settings.assistant.openai_api_key}
+ placeholder="sk-..."
+ 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"
+ />
+ <button
+ onclick={() => settingsState.loadOpenaiModels()}
+ disabled={settingsState.isLoadingOpenaiModels || !settingsState.settings.assistant.openai_api_key}
+ class="px-4 py-2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium flex items-center gap-2"
+ title="Refresh models"
+ >
+ <RefreshCw size={14} class={settingsState.isLoadingOpenaiModels ? "animate-spin" : ""} />
+ <span class="hidden sm:inline">Load</span>
+ </button>
+ </div>
+ <p class="text-xs text-white/30 mt-2">
+ Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank" class="text-indigo-400 hover:underline">OpenAI Dashboard</a>.
+ </p>
+ </div>
+
+ <div>
+ <label for="openai-endpoint" class="block text-sm font-medium text-white/70 mb-2">API Endpoint</label>
+ <input
+ id="openai-endpoint"
+ type="text"
+ bind:value={settingsState.settings.assistant.openai_endpoint}
+ placeholder="https://api.openai.com/v1"
+ 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 font-mono text-xs transition-colors"
+ />
+ <p class="text-xs text-white/30 mt-2">
+ Use custom endpoint for Azure OpenAI or other compatible APIs.
+ </p>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between mb-2">
+ <label for="openai-model" class="block text-sm font-medium text-white/70">Model</label>
+ {#if settingsState.openaiModels.length > 0}
+ <span class="text-[10px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
+ {settingsState.openaiModels.length} available
+ </span>
+ {/if}
+ </div>
+
+ {#if settingsState.isLoadingOpenaiModels}
+ <div class="bg-black/40 text-white/50 w-full px-4 py-3 rounded-xl border border-white/10 text-sm flex items-center gap-2">
+ <RefreshCw size={14} class="animate-spin" />
+ Loading models...
+ </div>
+ {:else if settingsState.openaiModelsError}
+ <div class="bg-red-500/10 text-red-400 w-full px-4 py-3 rounded-xl border border-red-500/20 text-sm mb-2">
+ {settingsState.openaiModelsError}
+ </div>
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.openai_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ allowCustom={true}
+ />
+ {:else}
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.openai_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ allowCustom={true}
+ />
+ {/if}
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <!-- Response Settings -->
+ <div class="pt-4 border-t dark:border-white/5 border-black/5">
+ <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Response Settings</h4>
+
+ <div class="space-y-4">
+ <div>
+ <label for="response-lang" class="block text-sm font-medium text-white/70 mb-2">Response Language</label>
+ <CustomSelect
+ options={languageOptions}
+ bind:value={settingsState.settings.assistant.response_language}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ />
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between mb-2">
+ <label for="system-prompt" class="block text-sm font-medium text-white/70">System Prompt</label>
+ <button
+ onclick={resetSystemPrompt}
+ class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1 opacity-80 hover:opacity-100"
+ title="Reset to default prompt"
+ >
+ <RefreshCw size={10} />
+ Reset
+ </button>
+ </div>
+
+ <div class="mb-3">
+ <CustomSelect
+ options={personas.map(p => ({ value: p.value, label: p.label }))}
+ bind:value={selectedPersona}
+ placeholder="Load a preset persona..."
+ onchange={applyPersona}
+ class="w-full"
+ />
+ </div>
+
+ <textarea
+ id="system-prompt"
+ bind:value={settingsState.settings.assistant.system_prompt}
+ oninput={() => selectedPersona = ""}
+ rows="4"
+ placeholder="You are a helpful Minecraft expert assistant..."
+ 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 text-sm transition-colors resize-none"
+ ></textarea>
+ <p class="text-xs text-white/30 mt-2">
+ Customize how the assistant behaves and responds.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <!-- TTS Settings -->
+ <div class="pt-4 border-t dark:border-white/5 border-black/5">
+ <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Text-to-Speech (Coming Soon)</h4>
+
+ <div class="space-y-4 opacity-50 pointer-events-none">
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80">Enable TTS</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Read assistant responses aloud.</p>
+ </div>
+ <button
+ disabled
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none 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 translate-x-0"></div>
+ </button>
+ </div>
+
+ <div>
+ <label class="block text-sm font-medium text-white/70 mb-2">TTS Provider</label>
+ <CustomSelect
+ options={ttsProviderOptions}
+ value="disabled"
+ class="w-full"
+ />
+ </div>
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <div class="pt-4 flex justify-end">
+ <button
+ onclick={() => settingsState.saveSettings()}
+ 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>
+ </div>
+ </div>
+</div>
+
+{#if settingsState.showConfigEditor}
+ <ConfigEditorModal />
+{/if}
+
+<!-- Java Download Modal -->
+{#if settingsState.showJavaDownloadModal}
+ <div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70">
+ <div class="bg-zinc-900 rounded-2xl border border-white/10 shadow-2xl w-[900px] max-w-[95vw] h-[600px] max-h-[90vh] flex flex-col overflow-hidden">
+ <!-- Header -->
+ <div class="flex items-center justify-between p-5 border-b border-white/10">
+ <h3 class="text-xl font-bold text-white">Download Java</h3>
+ <button
+ aria-label="Close dialog"
+ onclick={() => settingsState.closeJavaDownloadModal()}
+ disabled={settingsState.isDownloadingJava}
+ class="text-white/40 hover:text-white/80 disabled:opacity-50 transition-colors p-1"
+ >
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
+ </svg>
+ </button>
+ </div>
+
+ <!-- Main Content Area -->
+ <div class="flex flex-1 overflow-hidden">
+ <!-- Left Sidebar: Sources -->
+ <div class="w-40 border-r border-white/10 p-3 flex flex-col gap-1">
+ <span class="text-[10px] font-bold uppercase tracking-widest text-white/30 px-2 mb-2">Sources</span>
+
+ <button
+ disabled
+ class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm opacity-40 cursor-not-allowed text-white/50"
+ >
+ <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center text-[10px]">M</div>
+ Mojang
+ </button>
+
+ <button
+ class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm bg-indigo-500/20 border border-indigo-500/40 text-white"
+ >
+ <div class="w-5 h-5 rounded bg-indigo-500 flex items-center justify-center text-[10px] font-bold">A</div>
+ Adoptium
+ </button>
+
+ <button
+ disabled
+ class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm opacity-40 cursor-not-allowed text-white/50"
+ >
+ <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center text-[10px]">Z</div>
+ Azul Zulu
+ </button>
+ </div>
+
+ <!-- Center: Version Selection -->
+ <div class="flex-1 flex flex-col overflow-hidden">
+ <!-- Toolbar -->
+ <div class="flex items-center gap-3 p-4 border-b border-white/5">
+ <!-- Search -->
+ <div class="relative flex-1 max-w-xs">
+ <input
+ type="text"
+ bind:value={settingsState.searchQuery}
+ placeholder="Search versions..."
+ class="w-full bg-black/30 text-white text-sm px-4 py-2 pl-9 rounded-lg border border-white/10 focus:border-indigo-500/50 outline-none"
+ />
+ <svg class="absolute left-3 top-2.5 w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
+ </svg>
+ </div>
+
+ <!-- Recommended Filter -->
+ <label class="flex items-center gap-2 text-sm text-white/60 cursor-pointer select-none">
+ <input
+ type="checkbox"
+ bind:checked={settingsState.showOnlyRecommended}
+ class="w-4 h-4 rounded border-white/20 bg-black/30 text-indigo-500 focus:ring-indigo-500/30"
+ />
+ LTS Only
+ </label>
+
+ <!-- Image Type Toggle -->
+ <div class="flex items-center bg-black/30 rounded-lg p-0.5 border border-white/10">
+ <button
+ onclick={() => settingsState.selectedImageType = "jre"}
+ class="px-3 py-1.5 rounded-md text-xs font-medium transition-all {settingsState.selectedImageType === 'jre' ? 'bg-indigo-500 text-white shadow' : 'text-white/50 hover:text-white/80'}"
+ >
+ JRE
+ </button>
+ <button
+ onclick={() => settingsState.selectedImageType = "jdk"}
+ class="px-3 py-1.5 rounded-md text-xs font-medium transition-all {settingsState.selectedImageType === 'jdk' ? 'bg-indigo-500 text-white shadow' : 'text-white/50 hover:text-white/80'}"
+ >
+ JDK
+ </button>
+ </div>
+ </div>
+
+ <!-- Loading State -->
+ {#if settingsState.isLoadingCatalog}
+ <div class="flex-1 flex items-center justify-center text-white/50">
+ <div class="flex flex-col items-center gap-3">
+ <div class="w-8 h-8 border-2 border-indigo-500/30 border-t-indigo-500 rounded-full animate-spin"></div>
+ <span class="text-sm">Loading Java versions...</span>
+ </div>
+ </div>
+ {:else if settingsState.catalogError}
+ <div class="flex-1 flex items-center justify-center text-red-400">
+ <div class="flex flex-col items-center gap-3 text-center px-8">
+ <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
+ </svg>
+ <span class="text-sm">{settingsState.catalogError}</span>
+ <button
+ onclick={() => settingsState.refreshCatalog()}
+ class="mt-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-sm text-white transition-colors"
+ >
+ Retry
+ </button>
+ </div>
+ </div>
+ {:else}
+ <!-- Version List -->
+ <div class="flex-1 overflow-auto p-4">
+ <div class="space-y-2">
+ {#each settingsState.availableMajorVersions as version}
+ {@const isLts = settingsState.javaCatalog?.lts_versions.includes(version)}
+ {@const isSelected = settingsState.selectedMajorVersion === version}
+ {@const releaseInfo = settingsState.javaCatalog?.releases.find(r => r.major_version === version && r.image_type === settingsState.selectedImageType)}
+ {@const isAvailable = releaseInfo?.is_available ?? false}
+ {@const installStatus = releaseInfo ? settingsState.getInstallStatus(releaseInfo) : 'download'}
+
+ <button
+ onclick={() => settingsState.selectMajorVersion(version)}
+ disabled={!isAvailable}
+ class="w-full flex items-center gap-4 p-3 rounded-xl border transition-all text-left
+ {isSelected
+ ? 'bg-indigo-500/20 border-indigo-500/50 ring-2 ring-indigo-500/30'
+ : isAvailable
+ ? 'bg-black/20 border-white/10 hover:bg-white/5 hover:border-white/20'
+ : 'bg-black/10 border-white/5 opacity-40 cursor-not-allowed'}"
+ >
+ <!-- Version Number -->
+ <div class="w-14 text-center">
+ <span class="text-xl font-bold {isSelected ? 'text-white' : 'text-white/80'}">{version}</span>
+ </div>
+
+ <!-- Version Details -->
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center gap-2">
+ <span class="text-sm text-white/70 font-mono truncate">{releaseInfo?.version ?? '--'}</span>
+ {#if isLts}
+ <span class="px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 text-[9px] font-bold rounded uppercase shrink-0">LTS</span>
+ {/if}
+ </div>
+ {#if releaseInfo}
+ <div class="text-[10px] text-white/40 truncate mt-0.5">
+ {releaseInfo.release_name} • {settingsState.formatBytes(releaseInfo.file_size)}
+ </div>
+ {/if}
+ </div>
+
+ <!-- Install Status Badge -->
+ <div class="shrink-0">
+ {#if installStatus === 'installed'}
+ <span class="px-2 py-1 bg-emerald-500/20 text-emerald-400 text-[10px] font-bold rounded uppercase">Installed</span>
+ {:else if isAvailable}
+ <span class="px-2 py-1 bg-white/10 text-white/50 text-[10px] font-medium rounded">Download</span>
+ {:else}
+ <span class="px-2 py-1 bg-red-500/10 text-red-400/60 text-[10px] font-medium rounded">N/A</span>
+ {/if}
+ </div>
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Right Sidebar: Details -->
+ <div class="w-64 border-l border-white/10 flex flex-col">
+ <div class="p-4 border-b border-white/5">
+ <span class="text-[10px] font-bold uppercase tracking-widest text-white/30">Details</span>
+ </div>
+
+ {#if settingsState.selectedRelease}
+ <div class="flex-1 p-4 space-y-4 overflow-auto">
+ <div>
+ <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Version</div>
+ <div class="text-sm text-white font-mono">{settingsState.selectedRelease.version}</div>
+ </div>
+
+ <div>
+ <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Release Name</div>
+ <div class="text-sm text-white">{settingsState.selectedRelease.release_name}</div>
+ </div>
+
+ <div>
+ <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Release Date</div>
+ <div class="text-sm text-white">{settingsState.formatDate(settingsState.selectedRelease.release_date)}</div>
+ </div>
+
+ <div>
+ <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Size</div>
+ <div class="text-sm text-white">{settingsState.formatBytes(settingsState.selectedRelease.file_size)}</div>
+ </div>
+
+ <div>
+ <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Type</div>
+ <div class="flex items-center gap-2">
+ <span class="text-sm text-white uppercase">{settingsState.selectedRelease.image_type}</span>
+ {#if settingsState.selectedRelease.is_lts}
+ <span class="px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 text-[9px] font-bold rounded">LTS</span>
+ {/if}
+ </div>
+ </div>
+
+ <div>
+ <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Architecture</div>
+ <div class="text-sm text-white">{settingsState.selectedRelease.architecture}</div>
+ </div>
+
+ {#if !settingsState.selectedRelease.is_available}
+ <div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
+ <div class="text-xs text-red-400">Not available for your platform</div>
+ </div>
+ {/if}
+ </div>
+ {:else}
+ <div class="flex-1 flex items-center justify-center text-white/30 text-sm p-4 text-center">
+ Select a Java version to view details
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <!-- Download Progress (MC Style) -->
+ {#if settingsState.isDownloadingJava && settingsState.downloadProgress}
+ <div class="border-t border-white/10 p-4 bg-zinc-900/90">
+ <div class="flex items-center justify-between mb-2">
+ <h3 class="text-white font-bold text-sm">Downloading Java</h3>
+ <span class="text-xs text-zinc-400">{settingsState.downloadProgress.status}</span>
+ </div>
+
+ <!-- Progress Bar -->
+ <div class="mb-2">
+ <div class="flex justify-between text-[10px] text-zinc-400 mb-1">
+ <span>{settingsState.downloadProgress.file_name}</span>
+ <span>{Math.round(settingsState.downloadProgress.percentage)}%</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: {settingsState.downloadProgress.percentage}%"
+ ></div>
+ </div>
+ </div>
+
+ <!-- Speed & Stats -->
+ <div class="flex justify-between text-[10px] text-zinc-500 font-mono">
+ <span>
+ {settingsState.formatBytes(settingsState.downloadProgress.speed_bytes_per_sec)}/s ·
+ ETA: {settingsState.formatTime(settingsState.downloadProgress.eta_seconds)}
+ </span>
+ <span>
+ {settingsState.formatBytes(settingsState.downloadProgress.downloaded_bytes)} /
+ {settingsState.formatBytes(settingsState.downloadProgress.total_bytes)}
+ </span>
+ </div>
+ </div>
+ {/if}
+
+ <!-- Pending Downloads Alert -->
+ {#if settingsState.pendingDownloads.length > 0 && !settingsState.isDownloadingJava}
+ <div class="border-t border-amber-500/30 p-4 bg-amber-500/10">
+ <div class="flex items-center justify-between">
+ <div class="flex items-center gap-3">
+ <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
+ </svg>
+ <span class="text-sm text-amber-200">
+ {settingsState.pendingDownloads.length} pending download(s) can be resumed
+ </span>
+ </div>
+ <button
+ onclick={() => settingsState.resumeDownloads()}
+ class="px-4 py-2 bg-amber-500/20 hover:bg-amber-500/30 text-amber-200 rounded-lg text-sm font-medium transition-colors"
+ >
+ Resume All
+ </button>
+ </div>
+ </div>
+ {/if}
+
+ <!-- Footer Actions -->
+ <div class="flex items-center justify-between p-4 border-t border-white/10 bg-black/20">
+ <button
+ onclick={() => settingsState.refreshCatalog()}
+ disabled={settingsState.isLoadingCatalog || settingsState.isDownloadingJava}
+ class="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 disabled:opacity-50 text-white/70 rounded-lg text-sm transition-colors"
+ >
+ <svg class="w-4 h-4 {settingsState.isLoadingCatalog ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
+ </svg>
+ Refresh
+ </button>
+
+ <div class="flex gap-3">
+ {#if settingsState.isDownloadingJava}
+ <button
+ onclick={() => settingsState.cancelDownload()}
+ class="px-5 py-2.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-colors"
+ >
+ Cancel Download
+ </button>
+ {:else}
+ {@const isInstalled = settingsState.selectedRelease ? settingsState.getInstallStatus(settingsState.selectedRelease) === 'installed' : false}
+ <button
+ onclick={() => settingsState.closeJavaDownloadModal()}
+ class="px-5 py-2.5 bg-white/10 hover:bg-white/20 text-white rounded-lg text-sm font-medium transition-colors"
+ >
+ Close
+ </button>
+ <button
+ onclick={() => settingsState.downloadJava()}
+ disabled={!settingsState.selectedRelease?.is_available || settingsState.isLoadingCatalog || isInstalled}
+ class="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors"
+ >
+ {isInstalled ? 'Already Installed' : 'Download & Install'}
+ </button>
+ {/if}
+ </div>
+ </div>
+ </div>
+ </div>
+{/if}
diff --git a/packages/ui/src/components/Sidebar.svelte b/packages/ui/src/components/Sidebar.svelte
new file mode 100644
index 0000000..83f4ac6
--- /dev/null
+++ b/packages/ui/src/components/Sidebar.svelte
@@ -0,0 +1,91 @@
+<script lang="ts">
+ import { uiState } from '../stores/ui.svelte';
+ import { Home, Package, Settings, Bot, Folder } from 'lucide-svelte';
+</script>
+
+<aside
+ class="w-20 lg:w-64 dark:bg-[#09090b] bg-white border-r dark:border-white/10 border-gray-200 flex flex-col items-center lg:items-start transition-all duration-300 shrink-0 py-6 z-20"
+>
+ <!-- Logo Area -->
+ <div
+ class="h-16 w-full flex items-center justify-center lg:justify-start lg:px-6 mb-6"
+ >
+ <!-- Icon Logo (Small) -->
+ <div class="lg:hidden text-black dark:text-white">
+ <svg width="32" height="32" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M25 25 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
+ <path d="M25 75 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
+ <path d="M50 50 L75 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
+ <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
+ <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
+ <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
+ </svg>
+ </div>
+ <!-- Full Logo (Large) -->
+ <div
+ class="hidden lg:flex items-center gap-3 font-bold text-xl tracking-tighter dark:text-white text-black"
+ >
+ <!-- Neural Network Dropout Logo -->
+ <svg width="42" height="42" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" class="shrink-0">
+ <!-- Lines -->
+ <path d="M25 25 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
+ <path d="M25 75 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
+ <path d="M50 50 L75 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
+
+ <!-- Input Layer (Left) -->
+ <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
+
+ <!-- Hidden Layer (Middle) - Dropout visualization -->
+ <!-- Dropped units (dashed) -->
+ <circle cx="50" cy="25" r="7" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2" fill="none" class="opacity-30" />
+ <circle cx="50" cy="75" r="7" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2" fill="none" class="opacity-30" />
+ <!-- Active unit -->
+ <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
+
+ <!-- Output Layer (Right) -->
+ <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
+ </svg>
+
+ <span>DROPOUT</span>
+ </div>
+ </div>
+
+ <!-- Navigation -->
+ <nav class="flex-1 w-full flex flex-col gap-1 px-3">
+ <!-- Nav Item Helper -->
+ {#snippet navItem(view: any, Icon: any, label: string)}
+ <button
+ class="group flex items-center lg:gap-3 justify-center lg:justify-start w-full px-0 lg:px-4 py-2.5 rounded-sm transition-all duration-200 relative
+ {uiState.currentView === view
+ ? 'bg-black/5 dark:bg-white/10 dark:text-white text-black font-medium'
+ : 'dark:text-zinc-400 text-zinc-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
+ onclick={() => uiState.setView(view)}
+ >
+ <Icon size={20} strokeWidth={uiState.currentView === view ? 2.5 : 2} />
+ <span class="hidden lg:block text-sm relative z-10">{label}</span>
+
+ <!-- Active Indicator -->
+ {#if uiState.currentView === view}
+ <div class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-black dark:bg-white rounded-r-full hidden lg:block"></div>
+ {/if}
+ </button>
+ {/snippet}
+
+ {@render navItem('home', Home, 'Overview')}
+ {@render navItem('instances', Folder, 'Instances')}
+ {@render navItem('versions', Package, 'Versions')}
+ {@render navItem('guide', Bot, 'Assistant')}
+ {@render navItem('settings', Settings, 'Settings')}
+ </nav>
+
+ <!-- Footer Info -->
+ <div
+ class="p-4 w-full flex justify-center lg:justify-start lg:px-6 opacity-40 hover:opacity-100 transition-opacity"
+ >
+ <div class="text-[10px] font-mono text-zinc-500 uppercase tracking-wider">v{uiState.appVersion}</div>
+ </div>
+</aside>
diff --git a/packages/ui/src/components/StatusToast.svelte b/packages/ui/src/components/StatusToast.svelte
new file mode 100644
index 0000000..4c981c7
--- /dev/null
+++ b/packages/ui/src/components/StatusToast.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import { uiState } from "../stores/ui.svelte";
+</script>
+
+{#if uiState.status !== "Ready"}
+ {#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>
+ {/key}
+{/if}
+
+<style>
+ .progress-bar {
+ animation: progress 5s linear forwards;
+ }
+
+ @keyframes progress {
+ from {
+ transform: scaleX(1);
+ }
+ to {
+ transform: scaleX(0);
+ }
+ }
+</style>
diff --git a/packages/ui/src/components/VersionsView.svelte b/packages/ui/src/components/VersionsView.svelte
new file mode 100644
index 0000000..f1474d9
--- /dev/null
+++ b/packages/ui/src/components/VersionsView.svelte
@@ -0,0 +1,511 @@
+<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
+ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+ import { gameState } from "../stores/game.svelte";
+ import { instancesState } from "../stores/instances.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" | "installed">("all");
+
+ // Installed modded versions with Java version info (Fabric + Forge)
+ let installedFabricVersions = $state<Array<{ id: string; javaVersion?: number }>>([]);
+ let isLoadingModded = $state(false);
+
+ // Load installed modded versions with Java version info (both Fabric and Forge)
+ async function loadInstalledModdedVersions() {
+ if (!instancesState.activeInstanceId) {
+ installedFabricVersions = [];
+ isLoadingModded = false;
+ return;
+ }
+ isLoadingModded = true;
+ try {
+ // Get all installed versions and filter for modded ones (Fabric and Forge)
+ const allInstalled = await invoke<Array<{ id: string; type: string }>>(
+ "list_installed_versions",
+ { instanceId: instancesState.activeInstanceId }
+ );
+
+ // Filter for Fabric and Forge versions
+ const moddedIds = allInstalled
+ .filter(v => v.type === "fabric" || v.type === "forge")
+ .map(v => v.id);
+
+ // Load Java version for each installed modded version
+ const versionsWithJava = await Promise.all(
+ moddedIds.map(async (id) => {
+ try {
+ const javaVersion = await invoke<number | null>(
+ "get_version_java_version",
+ {
+ instanceId: instancesState.activeInstanceId!,
+ versionId: id,
+ }
+ );
+ return {
+ id,
+ javaVersion: javaVersion ?? undefined,
+ };
+ } catch (e) {
+ console.error(`Failed to get Java version for ${id}:`, e);
+ return { id, javaVersion: undefined };
+ }
+ })
+ );
+
+ installedFabricVersions = versionsWithJava;
+ } catch (e) {
+ console.error("Failed to load installed modded versions:", e);
+ } finally {
+ isLoadingModded = false;
+ }
+ }
+
+ let versionDeletedUnlisten: UnlistenFn | null = null;
+ let downloadCompleteUnlisten: UnlistenFn | null = null;
+ let versionInstalledUnlisten: UnlistenFn | null = null;
+ let fabricInstalledUnlisten: UnlistenFn | null = null;
+ let forgeInstalledUnlisten: UnlistenFn | null = null;
+
+ // Load on mount and setup event listeners
+ $effect(() => {
+ loadInstalledModdedVersions();
+ setupEventListeners();
+ return () => {
+ if (versionDeletedUnlisten) {
+ versionDeletedUnlisten();
+ }
+ if (downloadCompleteUnlisten) {
+ downloadCompleteUnlisten();
+ }
+ if (versionInstalledUnlisten) {
+ versionInstalledUnlisten();
+ }
+ if (fabricInstalledUnlisten) {
+ fabricInstalledUnlisten();
+ }
+ if (forgeInstalledUnlisten) {
+ forgeInstalledUnlisten();
+ }
+ };
+ });
+
+ async function setupEventListeners() {
+ // Refresh versions when a version is deleted
+ versionDeletedUnlisten = await listen("version-deleted", async () => {
+ await gameState.loadVersions();
+ await loadInstalledModdedVersions();
+ });
+
+ // Refresh versions when a download completes (version installed)
+ downloadCompleteUnlisten = await listen("download-complete", async () => {
+ await gameState.loadVersions();
+ await loadInstalledModdedVersions();
+ });
+
+ // Refresh when a version is installed
+ versionInstalledUnlisten = await listen("version-installed", async () => {
+ await gameState.loadVersions();
+ await loadInstalledModdedVersions();
+ });
+
+ // Refresh when Fabric is installed
+ fabricInstalledUnlisten = await listen("fabric-installed", async () => {
+ await gameState.loadVersions();
+ await loadInstalledModdedVersions();
+ });
+
+ // Refresh when Forge is installed
+ forgeInstalledUnlisten = await listen("forge-installed", async () => {
+ await gameState.loadVersions();
+ await loadInstalledModdedVersions();
+ });
+ }
+
+ // Combined versions list (vanilla + modded)
+ let allVersions = $derived(() => {
+ const moddedVersions = installedFabricVersions.map((v) => {
+ // Determine type based on version ID
+ const versionType = v.id.startsWith("fabric-loader-") ? "fabric" :
+ v.id.includes("-forge-") ? "forge" : "fabric";
+ return {
+ id: v.id,
+ type: versionType,
+ url: "",
+ time: "",
+ releaseTime: new Date().toISOString(),
+ javaVersion: v.javaVersion,
+ isInstalled: true, // Modded versions in the list are always installed
+ };
+ });
+ 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 === "installed") {
+ versions = versions.filter((v) => v.isInstalled === true);
+ }
+
+ // 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" };
+ case "modpack":
+ return { text: "Modpack", class: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-500/20 dark:text-purple-300 dark:border-purple-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();
+ // Refresh vanilla versions to update isInstalled status
+ gameState.loadVersions();
+ // Select the newly installed version
+ gameState.selectedVersion = versionId;
+ }
+
+ // Delete confirmation dialog state
+ let showDeleteDialog = $state(false);
+ let versionToDelete = $state<string | null>(null);
+
+ // Show delete confirmation dialog
+ function showDeleteConfirmation(versionId: string, event: MouseEvent) {
+ event.stopPropagation(); // Prevent version selection
+ versionToDelete = versionId;
+ showDeleteDialog = true;
+ }
+
+ // Cancel delete
+ function cancelDelete() {
+ showDeleteDialog = false;
+ versionToDelete = null;
+ }
+
+ // Confirm and delete version
+ async function confirmDelete() {
+ if (!versionToDelete) return;
+
+ try {
+ await invoke("delete_version", {
+ instanceId: instancesState.activeInstanceId,
+ versionId: versionToDelete
+ });
+ // Clear selection if deleted version was selected
+ if (gameState.selectedVersion === versionToDelete) {
+ gameState.selectedVersion = "";
+ }
+ // Close dialog
+ showDeleteDialog = false;
+ versionToDelete = null;
+ // Versions will be refreshed automatically via event listener
+ } catch (e) {
+ console.error("Failed to delete version:", e);
+ alert(`Failed to delete version: ${e}`);
+ // Keep dialog open on error so user can retry
+ }
+ }
+
+ // Version metadata for the selected version
+ interface VersionMetadata {
+ id: string;
+ javaVersion?: number;
+ isInstalled: boolean;
+ }
+
+ let selectedVersionMetadata = $state<VersionMetadata | null>(null);
+ let isLoadingMetadata = $state(false);
+
+ // Load metadata when version is selected
+ async function loadVersionMetadata(versionId: string) {
+ if (!versionId) {
+ selectedVersionMetadata = null;
+ return;
+ }
+
+ isLoadingMetadata = true;
+ try {
+ const metadata = await invoke<VersionMetadata>("get_version_metadata", {
+ instanceId: instancesState.activeInstanceId,
+ versionId,
+ });
+ selectedVersionMetadata = metadata;
+ } catch (e) {
+ console.error("Failed to load version metadata:", e);
+ selectedVersionMetadata = null;
+ } finally {
+ isLoadingMetadata = false;
+ }
+ }
+
+ // Watch for selected version changes
+ $effect(() => {
+ if (gameState.selectedVersion) {
+ loadVersionMetadata(gameState.selectedVersion);
+ } else {
+ selectedVersionMetadata = null;
+ }
+ });
+
+ // 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="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', 'installed'] 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>
+ {:else if filteredVersions().length === 0}
+ <div class="flex flex-col items-center justify-center h-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 flex-1">
+ <span
+ class="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border {badge.class}"
+ >
+ {badge.text}
+ </span>
+ <div class="flex-1">
+ <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>
+ <div class="flex items-center gap-2 mt-0.5">
+ {#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}
+ {#if version.javaVersion}
+ <div class="flex items-center gap-1 text-xs dark:text-white/40 text-black/40">
+ <span class="opacity-60">☕</span>
+ <span class="font-medium">Java {version.javaVersion}</span>
+ </div>
+ {/if}
+ </div>
+ </div>
+ </div>
+
+ <div class="relative z-10 flex items-center gap-2">
+ {#if version.isInstalled === true}
+ <button
+ onclick={(e) => showDeleteConfirmation(version.id, e)}
+ class="p-2 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-500/10 dark:hover:bg-red-500/20 transition-colors opacity-0 group-hover:opacity-100"
+ title="Delete version"
+ >
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M3 6h18"></path>
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+ </svg>
+ </button>
+ {/if}
+ {#if isSelected}
+ <div class="text-indigo-500 dark:text-indigo-400">
+ <span class="text-lg">Selected</span>
+ </div>
+ {/if}
+ </div>
+ </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 mb-4">
+ {gameState.selectedVersion}
+ </p>
+
+ <!-- Version Metadata -->
+ {#if isLoadingMetadata}
+ <div class="space-y-3 relative z-10">
+ <div class="animate-pulse space-y-2">
+ <div class="h-4 bg-black/10 dark:bg-white/10 rounded w-3/4"></div>
+ <div class="h-4 bg-black/10 dark:bg-white/10 rounded w-1/2"></div>
+ </div>
+ </div>
+ {:else if selectedVersionMetadata}
+ <div class="space-y-3 relative z-10">
+ <!-- Java Version -->
+ {#if selectedVersionMetadata.javaVersion}
+ <div>
+ <div class="text-[10px] font-bold uppercase tracking-wider dark:text-white/40 text-black/40 mb-1">Java Version</div>
+ <div class="flex items-center gap-2">
+ <span class="text-lg opacity-60">☕</span>
+ <span class="text-sm dark:text-white text-black font-medium">
+ Java {selectedVersionMetadata.javaVersion}
+ </span>
+ </div>
+ </div>
+ {/if}
+
+ <!-- Installation Status -->
+ <div>
+ <div class="text-[10px] font-bold uppercase tracking-wider dark:text-white/40 text-black/40 mb-1">Status</div>
+ <div class="flex items-center gap-2">
+ {#if selectedVersionMetadata.isInstalled === true}
+ <span class="px-2 py-0.5 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-[10px] font-bold rounded border border-emerald-500/30">
+ Installed
+ </span>
+ {:else if selectedVersionMetadata.isInstalled === false}
+ <span class="px-2 py-0.5 bg-zinc-500/20 text-zinc-600 dark:text-zinc-400 text-[10px] font-bold rounded border border-zinc-500/30">
+ Not Installed
+ </span>
+ {/if}
+ </div>
+ </div>
+ </div>
+ {/if}
+ {:else}
+ <p class="dark:text-white/20 text-black/20 italic relative z-10">None selected</p>
+ {/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>
+
+ <!-- Delete Version Confirmation Dialog -->
+ {#if showDeleteDialog && versionToDelete}
+ <div class="fixed inset-0 z-[200] bg-black/70 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
+ <div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200">
+ <h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Delete Version</h3>
+ <p class="text-zinc-600 dark:text-zinc-400 text-sm mb-6">
+ Are you sure you want to delete version <span class="text-gray-900 dark:text-white font-mono font-medium">{versionToDelete}</span>? This action cannot be undone.
+ </p>
+ <div class="flex gap-3 justify-end">
+ <button
+ onclick={cancelDelete}
+ class="px-4 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={confirmDelete}
+ class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors"
+ >
+ Delete
+ </button>
+ </div>
+ </div>
+ </div>
+ {/if}
+</div>
diff --git a/packages/ui/src/lib/Counter.svelte b/packages/ui/src/lib/Counter.svelte
new file mode 100644
index 0000000..37d75ce
--- /dev/null
+++ b/packages/ui/src/lib/Counter.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ let count: number = $state(0)
+ const increment = () => {
+ count += 1
+ }
+</script>
+
+<button onclick={increment}>
+ count is {count}
+</button>
diff --git a/packages/ui/src/lib/DownloadMonitor.svelte b/packages/ui/src/lib/DownloadMonitor.svelte
new file mode 100644
index 0000000..860952c
--- /dev/null
+++ b/packages/ui/src/lib/DownloadMonitor.svelte
@@ -0,0 +1,201 @@
+<script lang="ts">
+ import { listen } from "@tauri-apps/api/event";
+ import { onMount, onDestroy } from "svelte";
+
+ export let visible = false;
+
+ interface DownloadEvent {
+ file: string;
+ 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 (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;
+ let unlistenComplete: () => void;
+ 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>(
+ "download-progress",
+ (event) => {
+ const payload = event.payload;
+ currentFile = payload.file;
+
+ // Current file progress
+ downloadedBytes = payload.downloaded;
+ totalBytes = payload.total;
+
+ statusText = payload.status;
+
+ 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);
+ });
+ });
+
+ onDestroy(() => {
+ if (unlistenProgress) unlistenProgress();
+ if (unlistenStart) unlistenStart();
+ if (unlistenComplete) unlistenComplete();
+ });
+
+ function formatBytes(bytes: number) {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ 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 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>
+
+ <!-- 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"
+ style="width: {progress}%"
+ ></div>
+ </div>
+
+ <div class="flex justify-between text-[10px] text-zinc-500 font-mono">
+ <span>{formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}</span>
+ <span>{Math.round(progress)}%</span>
+ </div>
+ </div>
+{/if}
diff --git a/packages/ui/src/lib/GameConsole.svelte b/packages/ui/src/lib/GameConsole.svelte
new file mode 100644
index 0000000..bc5edbc
--- /dev/null
+++ b/packages/ui/src/lib/GameConsole.svelte
@@ -0,0 +1,304 @@
+<script lang="ts">
+ import { logsState, type LogEntry } from "../stores/logs.svelte";
+ import { uiState } from "../stores/ui.svelte";
+ import { save } from "@tauri-apps/plugin-dialog";
+ import { writeTextFile } from "@tauri-apps/plugin-fs";
+ import { invoke } from "@tauri-apps/api/core";
+ import { open } from "@tauri-apps/plugin-shell";
+ import { onMount, tick } from "svelte";
+ import CustomSelect from "../components/CustomSelect.svelte";
+ import { ChevronDown, Check } from 'lucide-svelte';
+
+ let consoleElement: HTMLDivElement;
+ let autoScroll = $state(true);
+
+ // Search & Filter
+ let searchQuery = $state("");
+ let showInfo = $state(true);
+ let showWarn = $state(true);
+ let showError = $state(true);
+ let showDebug = $state(false);
+
+ // Source filter: "all" or specific source name
+ let selectedSource = $state("all");
+
+ // Get sorted sources for dropdown
+ let sourceOptions = $derived([
+ { value: "all", label: "All Sources" },
+ ...[...logsState.sources].sort().map(s => ({ value: s, label: s }))
+ ]);
+
+ // Derived filtered logs
+ let filteredLogs = $derived(logsState.logs.filter((log) => {
+ // Source Filter
+ if (selectedSource !== "all" && log.source !== selectedSource) return false;
+
+ // Level Filter
+ if (!showInfo && log.level === "info") return false;
+ if (!showWarn && log.level === "warn") return false;
+ if (!showError && (log.level === "error" || log.level === "fatal")) return false;
+ if (!showDebug && log.level === "debug") return false;
+
+ // Search Filter
+ if (searchQuery) {
+ const q = searchQuery.toLowerCase();
+ return (
+ log.message.toLowerCase().includes(q) ||
+ log.source.toLowerCase().includes(q)
+ );
+ }
+ return true;
+ }));
+
+ // Auto-scroll logic
+ $effect(() => {
+ // Depend on filteredLogs length to trigger scroll
+ if (filteredLogs.length && autoScroll && consoleElement) {
+ // Use tick to wait for DOM update
+ tick().then(() => {
+ consoleElement.scrollTop = consoleElement.scrollHeight;
+ });
+ }
+ });
+
+ function handleScroll() {
+ if (!consoleElement) return;
+ const { scrollTop, scrollHeight, clientHeight } = consoleElement;
+ // If user scrolls up (more than 50px from bottom), disable auto-scroll
+ if (scrollHeight - scrollTop - clientHeight > 50) {
+ autoScroll = false;
+ } else {
+ autoScroll = true;
+ }
+ }
+
+ // Export only currently filtered logs
+ async function exportLogs() {
+ try {
+ const content = logsState.exportLogs(filteredLogs);
+ const path = await save({
+ filters: [{ name: "Log File", extensions: ["txt", "log"] }],
+ defaultPath: `dropout-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.log`,
+ });
+
+ if (path) {
+ await writeTextFile(path, content);
+ logsState.addLog("info", "Console", `Exported ${filteredLogs.length} logs to ${path}`);
+ }
+ } catch (e) {
+ console.error("Export failed", e);
+ logsState.addLog("error", "Console", `Export failed: ${e}`);
+ }
+ }
+
+ // Upload only currently filtered logs
+ async function uploadLogs() {
+ try {
+ const content = logsState.exportLogs(filteredLogs);
+ logsState.addLog("info", "Console", `Uploading ${filteredLogs.length} logs...`);
+
+ const response = await invoke<{ url: string }>("upload_to_pastebin", { content });
+
+ logsState.addLog("info", "Console", `Logs uploaded successfully: ${response.url}`);
+ await open(response.url);
+ } catch (e) {
+ console.error("Upload failed", e);
+ logsState.addLog("error", "Console", `Upload failed: ${e}`);
+ }
+ }
+
+ function highlightText(text: string, query: string) {
+ if (!query) return text;
+ // Escape regex special chars in query
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const parts = text.split(new RegExp(`(${escaped})`, "gi"));
+ return parts.map(part =>
+ part.toLowerCase() === query.toLowerCase()
+ ? `<span class="bg-yellow-500/30 text-yellow-200 font-bold">${part}</span>`
+ : part
+ ).join("");
+ }
+
+ function getLevelColor(level: LogEntry["level"]) {
+ switch (level) {
+ case "info": return "text-blue-400";
+ case "warn": return "text-yellow-400";
+ case "error":
+ case "fatal": return "text-red-400";
+ case "debug": return "text-purple-400";
+ default: return "text-zinc-400";
+ }
+ }
+
+ function getLevelLabel(level: LogEntry["level"]) {
+ switch (level) {
+ case "info": return "INFO";
+ case "warn": return "WARN";
+ case "error": return "ERR";
+ case "fatal": return "FATAL";
+ case "debug": return "DEBUG";
+ }
+ }
+
+ function getMessageColor(log: LogEntry) {
+ if (log.level === "error" || log.level === "fatal") return "text-red-300";
+ if (log.level === "warn") return "text-yellow-200";
+ if (log.level === "debug") return "text-purple-200/70";
+ if (log.source.startsWith("Game")) return "text-emerald-100/80";
+ return "";
+ }
+</script>
+
+<div class="absolute inset-0 flex flex-col bg-[#1e1e1e] text-zinc-300 font-mono text-xs overflow-hidden">
+ <!-- Toolbar -->
+ <div class="flex flex-wrap items-center justify-between p-2 bg-[#252526] border-b border-[#3e3e42] gap-2">
+ <div class="flex items-center gap-3">
+ <h3 class="font-bold text-zinc-100 uppercase tracking-wider px-2">Console</h3>
+
+ <!-- Source Dropdown -->
+ <CustomSelect
+ options={sourceOptions}
+ bind:value={selectedSource}
+ class="w-36"
+ />
+
+ <!-- Level Filters -->
+ <div class="flex items-center bg-[#1e1e1e] rounded border border-[#3e3e42] overflow-hidden">
+ <button
+ class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showInfo ? 'text-blue-400' : 'text-zinc-600'}"
+ onclick={() => showInfo = !showInfo}
+ title="Toggle Info"
+ >Info</button>
+ <div class="w-px h-3 bg-[#3e3e42]"></div>
+ <button
+ class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showWarn ? 'text-yellow-400' : 'text-zinc-600'}"
+ onclick={() => showWarn = !showWarn}
+ title="Toggle Warnings"
+ >Warn</button>
+ <div class="w-px h-3 bg-[#3e3e42]"></div>
+ <button
+ class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showError ? 'text-red-400' : 'text-zinc-600'}"
+ onclick={() => showError = !showError}
+ title="Toggle Errors"
+ >Error</button>
+ <div class="w-px h-3 bg-[#3e3e42]"></div>
+ <button
+ class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showDebug ? 'text-purple-400' : 'text-zinc-600'}"
+ onclick={() => showDebug = !showDebug}
+ title="Toggle Debug"
+ >Debug</button>
+ </div>
+
+ <!-- Search -->
+ <div class="relative group">
+ <input
+ type="text"
+ bind:value={searchQuery}
+ placeholder="Find..."
+ class="bg-[#1e1e1e] border border-[#3e3e42] rounded pl-8 pr-2 py-1 focus:border-indigo-500 focus:outline-none w-40 text-zinc-300 placeholder:text-zinc-600 transition-all focus:w-64"
+ />
+ <svg class="w-3.5 h-3.5 text-zinc-500 absolute left-2.5 top-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
+ {#if searchQuery}
+ <button class="absolute right-2 top-1.5 text-zinc-500 hover:text-white" onclick={() => searchQuery = ""}>✕</button>
+ {/if}
+ </div>
+ </div>
+
+ <!-- Actions -->
+ <div class="flex items-center gap-2">
+ <!-- Log count indicator -->
+ <span class="text-zinc-500 text-[10px] px-2">{filteredLogs.length} / {logsState.logs.length}</span>
+
+ <button
+ onclick={() => logsState.clear()}
+ class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
+ title="Clear Logs"
+ >
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
+ </button>
+ <button
+ onclick={exportLogs}
+ class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
+ title="Export Filtered Logs"
+ >
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
+ </button>
+ <button
+ onclick={uploadLogs}
+ class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
+ title="Upload Filtered Logs"
+ >
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
+ </button>
+ <div class="w-px h-4 bg-[#3e3e42] mx-1"></div>
+ <button
+ onclick={() => uiState.toggleConsole()}
+ class="p-1.5 hover:bg-red-500/20 hover:text-red-400 rounded text-zinc-400 transition-colors"
+ title="Close"
+ >
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
+ </button>
+ </div>
+ </div>
+
+ <!-- Log Area -->
+ <div
+ bind:this={consoleElement}
+ onscroll={handleScroll}
+ class="flex-1 overflow-y-auto overflow-x-hidden p-2 select-text custom-scrollbar"
+ >
+ {#each filteredLogs as log (log.id)}
+ <div class="flex gap-2 leading-relaxed hover:bg-[#2a2d2e] px-1 rounded-sm group">
+ <!-- Timestamp -->
+ <span class="text-zinc-500 shrink-0 select-none w-20 text-right opacity-50 group-hover:opacity-100">{log.timestamp.split('.')[0]}</span>
+
+ <!-- Source & Level -->
+ <div class="flex shrink-0 min-w-[140px] gap-1 justify-end font-bold select-none truncate">
+ <span class="text-zinc-500 truncate max-w-[90px]" title={log.source}>{log.source}</span>
+ <span class={getLevelColor(log.level)}>{getLevelLabel(log.level)}</span>
+ </div>
+
+ <!-- Message -->
+ <div class="flex-1 break-all whitespace-pre-wrap text-zinc-300 min-w-0 {getMessageColor(log)}">
+ {@html highlightText(log.message, searchQuery)}
+ </div>
+ </div>
+ {/each}
+
+ {#if filteredLogs.length === 0}
+ <div class="text-center text-zinc-600 mt-10 italic select-none">
+ {#if logsState.logs.length === 0}
+ Waiting for logs...
+ {:else}
+ No logs match current filters.
+ {/if}
+ </div>
+ {/if}
+ </div>
+
+ <!-- Auto-scroll status -->
+ {#if !autoScroll}
+ <button
+ onclick={() => { autoScroll = true; consoleElement.scrollTop = consoleElement.scrollHeight; }}
+ class="absolute bottom-4 right-6 bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded shadow-lg text-xs font-bold transition-all animate-bounce"
+ >
+ Resume Auto-scroll ⬇
+ </button>
+ {/if}
+</div>
+
+<style>
+ /* Custom Scrollbar for the log area */
+ .custom-scrollbar::-webkit-scrollbar {
+ width: 10px;
+ background-color: #1e1e1e;
+ }
+ .custom-scrollbar::-webkit-scrollbar-thumb {
+ background-color: #424242;
+ border: 2px solid #1e1e1e; /* padding around thumb */
+ border-radius: 0;
+ }
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background-color: #4f4f4f;
+ }
+</style>
diff --git a/packages/ui/src/lib/effects/ConstellationEffect.ts b/packages/ui/src/lib/effects/ConstellationEffect.ts
new file mode 100644
index 0000000..d2db529
--- /dev/null
+++ b/packages/ui/src/lib/effects/ConstellationEffect.ts
@@ -0,0 +1,162 @@
+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/packages/ui/src/lib/effects/SaturnEffect.ts b/packages/ui/src/lib/effects/SaturnEffect.ts
new file mode 100644
index 0000000..357da9d
--- /dev/null
+++ b/packages/ui/src/lib/effects/SaturnEffect.ts
@@ -0,0 +1,340 @@
+// 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;
+
+ // Mouse interaction properties
+ private isDragging: boolean = false;
+ private lastMouseX: number = 0;
+ private lastMouseTime: number = 0;
+ private mouseVelocities: number[] = []; // Store recent velocities for averaging
+
+ // Rotation speed control
+ private readonly baseSpeed: number = 0.005; // Original rotation speed
+ private currentSpeed: number = 0.005; // Current rotation speed (can be modified by mouse)
+ private rotationDirection: number = 1; // 1 for clockwise, -1 for counter-clockwise
+ private readonly speedDecayRate: number = 0.992; // How fast speed returns to normal (closer to 1 = slower decay)
+ private readonly minSpeedMultiplier: number = 1; // Minimum speed is baseSpeed
+ private readonly maxSpeedMultiplier: number = 50; // Maximum speed is 50x baseSpeed
+ private isStopped: boolean = false; // Whether the user has stopped the rotation
+
+ 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();
+ }
+
+ // Public methods for external mouse event handling
+ // These can be called from any element that wants to control the Saturn rotation
+
+ handleMouseDown(clientX: number) {
+ this.isDragging = true;
+ this.lastMouseX = clientX;
+ this.lastMouseTime = performance.now();
+ this.mouseVelocities = [];
+ }
+
+ handleMouseMove(clientX: number) {
+ if (!this.isDragging) return;
+
+ const currentTime = performance.now();
+ const deltaTime = currentTime - this.lastMouseTime;
+
+ if (deltaTime > 0) {
+ const deltaX = clientX - this.lastMouseX;
+ const velocity = deltaX / deltaTime; // pixels per millisecond
+
+ // Store recent velocities (keep last 5 for smoothing)
+ this.mouseVelocities.push(velocity);
+ if (this.mouseVelocities.length > 5) {
+ this.mouseVelocities.shift();
+ }
+
+ // Apply direct rotation while dragging
+ this.angle += deltaX * 0.002;
+ }
+
+ this.lastMouseX = clientX;
+ this.lastMouseTime = currentTime;
+ }
+
+ handleMouseUp() {
+ if (this.isDragging && this.mouseVelocities.length > 0) {
+ this.applyFlingVelocity();
+ }
+ this.isDragging = false;
+ }
+
+ handleTouchStart(clientX: number) {
+ this.isDragging = true;
+ this.lastMouseX = clientX;
+ this.lastMouseTime = performance.now();
+ this.mouseVelocities = [];
+ }
+
+ handleTouchMove(clientX: number) {
+ if (!this.isDragging) return;
+
+ const currentTime = performance.now();
+ const deltaTime = currentTime - this.lastMouseTime;
+
+ if (deltaTime > 0) {
+ const deltaX = clientX - this.lastMouseX;
+ const velocity = deltaX / deltaTime;
+
+ this.mouseVelocities.push(velocity);
+ if (this.mouseVelocities.length > 5) {
+ this.mouseVelocities.shift();
+ }
+
+ this.angle += deltaX * 0.002;
+ }
+
+ this.lastMouseX = clientX;
+ this.lastMouseTime = currentTime;
+ }
+
+ handleTouchEnd() {
+ if (this.isDragging && this.mouseVelocities.length > 0) {
+ this.applyFlingVelocity();
+ }
+ this.isDragging = false;
+ }
+
+ private applyFlingVelocity() {
+ // Calculate average velocity from recent samples
+ const avgVelocity =
+ this.mouseVelocities.reduce((a, b) => a + b, 0) / this.mouseVelocities.length;
+
+ // Threshold for considering it a "fling" (pixels per millisecond)
+ const flingThreshold = 0.3;
+ // Threshold for considering the rotation as "stopped" by user
+ const stopThreshold = 0.1;
+
+ if (Math.abs(avgVelocity) > flingThreshold) {
+ // User flung it - start rotating again
+ this.isStopped = false;
+
+ // Determine new direction based on fling direction
+ const newDirection = avgVelocity > 0 ? 1 : -1;
+
+ // If direction changed, update it permanently
+ if (newDirection !== this.rotationDirection) {
+ this.rotationDirection = newDirection;
+ }
+
+ // Calculate speed boost based on fling strength
+ // Map velocity to speed multiplier (stronger fling = faster rotation)
+ const speedMultiplier = Math.min(
+ this.maxSpeedMultiplier,
+ this.minSpeedMultiplier + Math.abs(avgVelocity) * 10,
+ );
+
+ this.currentSpeed = this.baseSpeed * speedMultiplier;
+ } else if (Math.abs(avgVelocity) < stopThreshold) {
+ // User gently released - keep it stopped
+ this.isStopped = true;
+ this.currentSpeed = 0;
+ }
+ // If velocity is between stopThreshold and flingThreshold,
+ // keep current state (don't change isStopped)
+ }
+
+ 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";
+
+ // Update rotation speed - decay towards base speed while maintaining direction
+ if (!this.isDragging && !this.isStopped) {
+ if (this.currentSpeed > this.baseSpeed) {
+ // Gradually decay speed back to base speed
+ this.currentSpeed =
+ this.baseSpeed + (this.currentSpeed - this.baseSpeed) * this.speedDecayRate;
+
+ // Snap to base speed when close enough
+ if (this.currentSpeed - this.baseSpeed < 0.00001) {
+ this.currentSpeed = this.baseSpeed;
+ }
+ }
+
+ // Apply rotation with current speed and direction
+ this.angle += this.currentSpeed * this.rotationDirection;
+ }
+
+ 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: Warm 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/packages/ui/src/lib/modLoaderApi.ts b/packages/ui/src/lib/modLoaderApi.ts
new file mode 100644
index 0000000..75f404a
--- /dev/null
+++ b/packages/ui/src/lib/modLoaderApi.ts
@@ -0,0 +1,106 @@
+/**
+ * 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/packages/ui/src/main.ts b/packages/ui/src/main.ts
new file mode 100644
index 0000000..d47b930
--- /dev/null
+++ b/packages/ui/src/main.ts
@@ -0,0 +1,9 @@
+import { mount } from "svelte";
+import "./app.css";
+import App from "./App.svelte";
+
+const app = mount(App, {
+ target: document.getElementById("app")!,
+});
+
+export default app;
diff --git a/packages/ui/src/stores/assistant.svelte.ts b/packages/ui/src/stores/assistant.svelte.ts
new file mode 100644
index 0000000..a3f47ea
--- /dev/null
+++ b/packages/ui/src/stores/assistant.svelte.ts
@@ -0,0 +1,166 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+
+export interface GenerationStats {
+ total_duration: number;
+ load_duration: number;
+ prompt_eval_count: number;
+ prompt_eval_duration: number;
+ eval_count: number;
+ eval_duration: number;
+}
+
+export interface Message {
+ role: "user" | "assistant" | "system";
+ content: string;
+ stats?: GenerationStats;
+}
+
+interface StreamChunk {
+ content: string;
+ done: boolean;
+ stats?: GenerationStats;
+}
+
+// Module-level state using $state
+let messages = $state<Message[]>([]);
+let isProcessing = $state(false);
+let isProviderHealthy = $state(false);
+let streamingContent = "";
+let initialized = false;
+let streamUnlisten: UnlistenFn | null = null;
+
+async function init() {
+ if (initialized) return;
+ initialized = true;
+ await checkHealth();
+}
+
+async function checkHealth() {
+ try {
+ isProviderHealthy = await invoke("assistant_check_health");
+ } catch (e) {
+ console.error("Failed to check provider health:", e);
+ isProviderHealthy = false;
+ }
+}
+
+function finishStreaming() {
+ isProcessing = false;
+ streamingContent = "";
+ if (streamUnlisten) {
+ streamUnlisten();
+ streamUnlisten = null;
+ }
+}
+
+async function sendMessage(
+ content: string,
+ isEnabled: boolean,
+ provider: string,
+ endpoint: string,
+) {
+ if (!content.trim()) return;
+ if (!isEnabled) {
+ messages = [
+ ...messages,
+ {
+ role: "assistant",
+ content: "Assistant is disabled. Enable it in Settings > AI Assistant.",
+ },
+ ];
+ return;
+ }
+
+ // Add user message
+ messages = [...messages, { role: "user", content }];
+ isProcessing = true;
+ streamingContent = "";
+
+ // Add empty assistant message for streaming
+ messages = [...messages, { role: "assistant", content: "" }];
+
+ try {
+ // Set up stream listener
+ streamUnlisten = await listen<StreamChunk>("assistant-stream", (event) => {
+ const chunk = event.payload;
+
+ if (chunk.content) {
+ streamingContent += chunk.content;
+ // Update the last message (assistant's response)
+ const lastIdx = messages.length - 1;
+ if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
+ messages[lastIdx] = {
+ ...messages[lastIdx],
+ content: streamingContent,
+ };
+ // Trigger reactivity
+ messages = [...messages];
+ }
+ }
+
+ if (chunk.done) {
+ if (chunk.stats) {
+ const lastIdx = messages.length - 1;
+ if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
+ messages[lastIdx] = {
+ ...messages[lastIdx],
+ stats: chunk.stats,
+ };
+ messages = [...messages];
+ }
+ }
+ finishStreaming();
+ }
+ });
+
+ // Start streaming chat
+ await invoke<string>("assistant_chat_stream", {
+ messages: messages.slice(0, -1), // Exclude the empty assistant message
+ });
+ } catch (e) {
+ console.error("Failed to send message:", e);
+ const errorMessage = e instanceof Error ? e.message : String(e);
+
+ let helpText = "";
+ if (provider === "ollama") {
+ helpText = `\n\nPlease ensure Ollama is running at ${endpoint}.`;
+ } else if (provider === "openai") {
+ helpText = "\n\nPlease check your OpenAI API key in Settings.";
+ }
+
+ // Update the last message with error
+ const lastIdx = messages.length - 1;
+ if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
+ messages[lastIdx] = {
+ role: "assistant",
+ content: `Error: ${errorMessage}${helpText}`,
+ };
+ messages = [...messages];
+ }
+
+ finishStreaming();
+ }
+}
+
+function clearHistory() {
+ messages = [];
+ streamingContent = "";
+}
+
+// Export as an object with getters for reactive access
+export const assistantState = {
+ get messages() {
+ return messages;
+ },
+ get isProcessing() {
+ return isProcessing;
+ },
+ get isProviderHealthy() {
+ return isProviderHealthy;
+ },
+ init,
+ checkHealth,
+ sendMessage,
+ clearHistory,
+};
diff --git a/packages/ui/src/stores/auth.svelte.ts b/packages/ui/src/stores/auth.svelte.ts
new file mode 100644
index 0000000..1b613a7
--- /dev/null
+++ b/packages/ui/src/stores/auth.svelte.ts
@@ -0,0 +1,192 @@
+import { invoke } from "@tauri-apps/api/core";
+import { open } from "@tauri-apps/plugin-shell";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import type { Account, DeviceCodeResponse } from "../types";
+import { uiState } from "./ui.svelte";
+import { logsState } from "./logs.svelte";
+
+export class AuthState {
+ currentAccount = $state<Account | null>(null);
+ isLoginModalOpen = $state(false);
+ isLogoutConfirmOpen = $state(false);
+ loginMode = $state<"select" | "offline" | "microsoft">("select");
+ offlineUsername = $state("");
+ deviceCodeData = $state<DeviceCodeResponse | null>(null);
+ msLoginLoading = $state(false);
+ msLoginStatus = $state("Waiting for authorization...");
+
+ private pollInterval: ReturnType<typeof setInterval> | null = null;
+ private isPollingRequestActive = false;
+ private authProgressUnlisten: UnlistenFn | null = null;
+
+ async checkAccount() {
+ try {
+ const acc = await invoke("get_active_account");
+ this.currentAccount = acc as Account | null;
+ } catch (e) {
+ console.error("Failed to check account:", e);
+ }
+ }
+
+ openLoginModal() {
+ if (this.currentAccount) {
+ // Show custom logout confirmation dialog
+ this.isLogoutConfirmOpen = true;
+ return;
+ }
+ this.resetLoginState();
+ this.isLoginModalOpen = true;
+ }
+
+ cancelLogout() {
+ this.isLogoutConfirmOpen = false;
+ }
+
+ async confirmLogout() {
+ this.isLogoutConfirmOpen = false;
+ try {
+ await invoke("logout");
+ this.currentAccount = null;
+ uiState.setStatus("Logged out successfully");
+ } catch (e) {
+ console.error("Logout failed:", e);
+ }
+ }
+
+ closeLoginModal() {
+ this.stopPolling();
+ this.isLoginModalOpen = false;
+ }
+
+ resetLoginState() {
+ this.loginMode = "select";
+ this.offlineUsername = "";
+ this.deviceCodeData = null;
+ this.msLoginLoading = false;
+ }
+
+ async performOfflineLogin() {
+ if (!this.offlineUsername) return;
+ try {
+ this.currentAccount = (await invoke("login_offline", {
+ username: this.offlineUsername,
+ })) as Account;
+ this.isLoginModalOpen = false;
+ } catch (e) {
+ alert("Login failed: " + e);
+ }
+ }
+
+ async startMicrosoftLogin() {
+ this.loginMode = "microsoft";
+ this.msLoginLoading = true;
+ this.msLoginStatus = "Waiting for authorization...";
+ this.stopPolling();
+
+ // Setup auth progress listener
+ this.setupAuthProgressListener();
+
+ try {
+ this.deviceCodeData = (await invoke("start_microsoft_login")) as DeviceCodeResponse;
+
+ if (this.deviceCodeData) {
+ try {
+ await navigator.clipboard.writeText(this.deviceCodeData.user_code);
+ } catch (e) {
+ console.error("Clipboard failed", e);
+ }
+
+ open(this.deviceCodeData.verification_uri);
+ logsState.addLog(
+ "info",
+ "Auth",
+ "Microsoft login started, waiting for browser authorization...",
+ );
+
+ console.log("Starting polling for token...");
+ const intervalMs = (this.deviceCodeData.interval || 5) * 1000;
+ this.pollInterval = setInterval(
+ () => this.checkLoginStatus(this.deviceCodeData!.device_code),
+ intervalMs,
+ );
+ }
+ } catch (e) {
+ logsState.addLog("error", "Auth", `Failed to start Microsoft login: ${e}`);
+ alert("Failed to start Microsoft login: " + e);
+ this.loginMode = "select";
+ } finally {
+ this.msLoginLoading = false;
+ }
+ }
+
+ private async setupAuthProgressListener() {
+ // Clean up previous listener if exists
+ if (this.authProgressUnlisten) {
+ this.authProgressUnlisten();
+ this.authProgressUnlisten = null;
+ }
+
+ this.authProgressUnlisten = await listen<string>("auth-progress", (event) => {
+ const message = event.payload;
+ this.msLoginStatus = message;
+ logsState.addLog("info", "Auth", message);
+ });
+ }
+
+ private cleanupAuthListener() {
+ if (this.authProgressUnlisten) {
+ this.authProgressUnlisten();
+ this.authProgressUnlisten = null;
+ }
+ }
+
+ stopPolling() {
+ if (this.pollInterval) {
+ clearInterval(this.pollInterval);
+ this.pollInterval = null;
+ }
+ }
+
+ async checkLoginStatus(deviceCode: string) {
+ if (this.isPollingRequestActive) return;
+ this.isPollingRequestActive = true;
+
+ console.log("Polling Microsoft API...");
+ try {
+ this.currentAccount = (await invoke("complete_microsoft_login", {
+ deviceCode,
+ })) as Account;
+
+ console.log("Login Successful!", this.currentAccount);
+ this.stopPolling();
+ this.cleanupAuthListener();
+ this.isLoginModalOpen = false;
+ logsState.addLog(
+ "info",
+ "Auth",
+ `Login successful! Welcome, ${this.currentAccount.username}`,
+ );
+ uiState.setStatus("Welcome back, " + this.currentAccount.username);
+ } catch (e: any) {
+ const errStr = e.toString();
+ if (errStr.includes("authorization_pending")) {
+ console.log("Status: Waiting for user to authorize...");
+ } else {
+ console.error("Polling Error:", errStr);
+ this.msLoginStatus = "Error: " + errStr;
+ logsState.addLog("error", "Auth", `Login error: ${errStr}`);
+
+ if (errStr.includes("expired_token") || errStr.includes("access_denied")) {
+ this.stopPolling();
+ this.cleanupAuthListener();
+ alert("Login failed: " + errStr);
+ this.loginMode = "select";
+ }
+ }
+ } finally {
+ this.isPollingRequestActive = false;
+ }
+ }
+}
+
+export const authState = new AuthState();
diff --git a/packages/ui/src/stores/game.svelte.ts b/packages/ui/src/stores/game.svelte.ts
new file mode 100644
index 0000000..504d108
--- /dev/null
+++ b/packages/ui/src/stores/game.svelte.ts
@@ -0,0 +1,78 @@
+import { invoke } from "@tauri-apps/api/core";
+import type { Version } from "../types";
+import { uiState } from "./ui.svelte";
+import { authState } from "./auth.svelte";
+import { instancesState } from "./instances.svelte";
+
+export class GameState {
+ versions = $state<Version[]>([]);
+ selectedVersion = $state("");
+
+ constructor() {
+ // Constructor intentionally empty
+ // Instance switching handled in App.svelte with $effect
+ }
+
+ get latestRelease() {
+ return this.versions.find((v) => v.type === "release");
+ }
+
+ async loadVersions(instanceId?: string) {
+ const id = instanceId || instancesState.activeInstanceId;
+ if (!id) {
+ this.versions = [];
+ return;
+ }
+
+ try {
+ this.versions = await invoke<Version[]>("get_versions", {
+ instanceId: id,
+ });
+ // Don't auto-select version here - let BottomBar handle version selection
+ // based on installed versions only
+ } catch (e) {
+ console.error("Failed to fetch versions:", e);
+ uiState.setStatus("Error fetching versions: " + e);
+ }
+ }
+
+ async startGame() {
+ if (!authState.currentAccount) {
+ alert("Please login first!");
+ authState.openLoginModal();
+ return;
+ }
+
+ if (!this.selectedVersion) {
+ alert("Please select a version!");
+ return;
+ }
+
+ if (!instancesState.activeInstanceId) {
+ alert("Please select an instance first!");
+ uiState.setView("instances");
+ return;
+ }
+
+ uiState.setStatus("Preparing to launch " + this.selectedVersion + "...");
+ console.log(
+ "Invoking start_game for version:",
+ this.selectedVersion,
+ "instance:",
+ instancesState.activeInstanceId,
+ );
+ try {
+ const msg = await invoke<string>("start_game", {
+ instanceId: instancesState.activeInstanceId,
+ versionId: this.selectedVersion,
+ });
+ console.log("Response:", msg);
+ uiState.setStatus(msg);
+ } catch (e) {
+ console.error(e);
+ uiState.setStatus("Error: " + e);
+ }
+ }
+}
+
+export const gameState = new GameState();
diff --git a/packages/ui/src/stores/instances.svelte.ts b/packages/ui/src/stores/instances.svelte.ts
new file mode 100644
index 0000000..f4ac4e9
--- /dev/null
+++ b/packages/ui/src/stores/instances.svelte.ts
@@ -0,0 +1,109 @@
+import { invoke } from "@tauri-apps/api/core";
+import type { Instance } from "../types";
+import { uiState } from "./ui.svelte";
+
+export class InstancesState {
+ instances = $state<Instance[]>([]);
+ activeInstanceId = $state<string | null>(null);
+ get activeInstance(): Instance | null {
+ if (!this.activeInstanceId) return null;
+ return this.instances.find((i) => i.id === this.activeInstanceId) || null;
+ }
+
+ async loadInstances() {
+ try {
+ this.instances = await invoke<Instance[]>("list_instances");
+ const active = await invoke<Instance | null>("get_active_instance");
+ if (active) {
+ this.activeInstanceId = active.id;
+ } else if (this.instances.length > 0) {
+ // If no active instance but instances exist, set the first one as active
+ await this.setActiveInstance(this.instances[0].id);
+ }
+ } catch (e) {
+ console.error("Failed to load instances:", e);
+ uiState.setStatus("Error loading instances: " + e);
+ }
+ }
+
+ async createInstance(name: string): Promise<Instance | null> {
+ try {
+ const instance = await invoke<Instance>("create_instance", { name });
+ await this.loadInstances();
+ uiState.setStatus(`Instance "${name}" created successfully`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ uiState.setStatus("Error creating instance: " + e);
+ return null;
+ }
+ }
+
+ async deleteInstance(id: string) {
+ try {
+ await invoke("delete_instance", { instanceId: id });
+ await this.loadInstances();
+ // If deleted instance was active, set another as active
+ if (this.activeInstanceId === id) {
+ if (this.instances.length > 0) {
+ await this.setActiveInstance(this.instances[0].id);
+ } else {
+ this.activeInstanceId = null;
+ }
+ }
+ uiState.setStatus("Instance deleted successfully");
+ } catch (e) {
+ console.error("Failed to delete instance:", e);
+ uiState.setStatus("Error deleting instance: " + e);
+ }
+ }
+
+ async updateInstance(instance: Instance) {
+ try {
+ await invoke("update_instance", { instance });
+ await this.loadInstances();
+ uiState.setStatus("Instance updated successfully");
+ } catch (e) {
+ console.error("Failed to update instance:", e);
+ uiState.setStatus("Error updating instance: " + e);
+ }
+ }
+
+ async setActiveInstance(id: string) {
+ try {
+ await invoke("set_active_instance", { instanceId: id });
+ this.activeInstanceId = id;
+ uiState.setStatus("Active instance changed");
+ } catch (e) {
+ console.error("Failed to set active instance:", e);
+ uiState.setStatus("Error setting active instance: " + e);
+ }
+ }
+
+ async duplicateInstance(id: string, newName: string): Promise<Instance | null> {
+ try {
+ const instance = await invoke<Instance>("duplicate_instance", {
+ instanceId: id,
+ newName,
+ });
+ await this.loadInstances();
+ uiState.setStatus(`Instance duplicated as "${newName}"`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to duplicate instance:", e);
+ uiState.setStatus("Error duplicating instance: " + e);
+ return null;
+ }
+ }
+
+ async getInstance(id: string): Promise<Instance | null> {
+ try {
+ return await invoke<Instance>("get_instance", { instanceId: id });
+ } catch (e) {
+ console.error("Failed to get instance:", e);
+ return null;
+ }
+ }
+}
+
+export const instancesState = new InstancesState();
diff --git a/packages/ui/src/stores/logs.svelte.ts b/packages/ui/src/stores/logs.svelte.ts
new file mode 100644
index 0000000..c9d4acc
--- /dev/null
+++ b/packages/ui/src/stores/logs.svelte.ts
@@ -0,0 +1,151 @@
+import { listen } from "@tauri-apps/api/event";
+
+export interface LogEntry {
+ id: number;
+ timestamp: string;
+ level: "info" | "warn" | "error" | "debug" | "fatal";
+ source: string;
+ message: string;
+}
+
+// Parse Minecraft/Java log format: [HH:MM:SS] [Thread/LEVEL]: message
+// or: [HH:MM:SS] [Thread/LEVEL] [Source]: message
+const GAME_LOG_REGEX = /^\[[\d:]+\]\s*\[([^\]]+)\/(\w+)\](?:\s*\[([^\]]+)\])?:\s*(.*)$/;
+
+function parseGameLogLevel(levelStr: string): LogEntry["level"] {
+ const upper = levelStr.toUpperCase();
+ if (upper === "INFO") return "info";
+ if (upper === "WARN" || upper === "WARNING") return "warn";
+ if (upper === "ERROR" || upper === "SEVERE") return "error";
+ if (
+ upper === "DEBUG" ||
+ upper === "TRACE" ||
+ upper === "FINE" ||
+ upper === "FINER" ||
+ upper === "FINEST"
+ )
+ return "debug";
+ if (upper === "FATAL") return "fatal";
+ return "info";
+}
+
+export class LogsState {
+ logs = $state<LogEntry[]>([]);
+ private nextId = 0;
+ private maxLogs = 5000;
+
+ // Track all unique sources for filtering
+ sources = $state<Set<string>>(new Set(["Launcher"]));
+
+ constructor() {
+ this.addLog("info", "Launcher", "Logs initialized");
+ }
+
+ addLog(level: LogEntry["level"], source: string, message: string) {
+ const now = new Date();
+ const timestamp =
+ now.toLocaleTimeString() + "." + now.getMilliseconds().toString().padStart(3, "0");
+
+ this.logs.push({
+ id: this.nextId++,
+ timestamp,
+ level,
+ source,
+ message,
+ });
+
+ // Track source
+ if (!this.sources.has(source)) {
+ this.sources = new Set([...this.sources, source]);
+ }
+
+ if (this.logs.length > this.maxLogs) {
+ this.logs.shift();
+ }
+ }
+
+ // Parse game output and extract level/source
+ addGameLog(rawLine: string, isStderr: boolean) {
+ const match = rawLine.match(GAME_LOG_REGEX);
+
+ if (match) {
+ const [, thread, levelStr, extraSource, message] = match;
+ const level = parseGameLogLevel(levelStr);
+ // Use extraSource if available, otherwise use thread name as source hint
+ const source = extraSource || `Game/${thread.split("-")[0]}`;
+ this.addLog(level, source, message);
+ } else {
+ // Fallback: couldn't parse, use stderr as error indicator
+ const level = isStderr ? "error" : "info";
+ this.addLog(level, "Game", rawLine);
+ }
+ }
+
+ clear() {
+ this.logs = [];
+ this.sources = new Set(["Launcher"]);
+ this.addLog("info", "Launcher", "Logs cleared");
+ }
+
+ // Export with filter support
+ exportLogs(filteredLogs: LogEntry[]): string {
+ return filteredLogs
+ .map((l) => `[${l.timestamp}] [${l.source}/${l.level.toUpperCase()}] ${l.message}`)
+ .join("\n");
+ }
+
+ private initialized = false;
+
+ async init() {
+ if (this.initialized) return;
+ this.initialized = true;
+
+ // General Launcher Logs
+ await listen<string>("launcher-log", (e) => {
+ this.addLog("info", "Launcher", e.payload);
+ });
+
+ // Game Stdout - parse log level
+ await listen<string>("game-stdout", (e) => {
+ this.addGameLog(e.payload, false);
+ });
+
+ // Game Stderr - parse log level, default to error
+ await listen<string>("game-stderr", (e) => {
+ this.addGameLog(e.payload, true);
+ });
+
+ // Download Events (Summarized)
+ await listen("download-start", (e) => {
+ this.addLog("info", "Downloader", `Starting batch download of ${e.payload} files...`);
+ });
+
+ await listen("download-complete", () => {
+ this.addLog("info", "Downloader", "All downloads completed.");
+ });
+
+ // Listen to file download progress to log finished files
+ await listen<any>("download-progress", (e) => {
+ const p = e.payload;
+ if (p.status === "Finished") {
+ if (p.file.endsWith(".jar")) {
+ this.addLog("info", "Downloader", `Downloaded ${p.file}`);
+ }
+ }
+ });
+
+ // Java Download
+ await listen<any>("java-download-progress", (e) => {
+ const p = e.payload;
+ if (p.status === "Downloading" && p.percentage === 0) {
+ this.addLog("info", "JavaInstaller", `Downloading Java: ${p.file_name}`);
+ } else if (p.status === "Completed") {
+ this.addLog("info", "JavaInstaller", `Java installed: ${p.file_name}`);
+ } else if (p.status === "Error") {
+ this.addLog("error", "JavaInstaller", `Java download error`);
+ }
+ });
+ }
+}
+
+export const logsState = new LogsState();
diff --git a/packages/ui/src/stores/releases.svelte.ts b/packages/ui/src/stores/releases.svelte.ts
new file mode 100644
index 0000000..c858abb
--- /dev/null
+++ b/packages/ui/src/stores/releases.svelte.ts
@@ -0,0 +1,36 @@
+import { invoke } from "@tauri-apps/api/core";
+
+export interface GithubRelease {
+ tag_name: string;
+ name: string;
+ published_at: string;
+ body: string;
+ html_url: string;
+}
+
+export class ReleasesState {
+ releases = $state<GithubRelease[]>([]);
+ isLoading = $state(false);
+ isLoaded = $state(false);
+ error = $state<string | null>(null);
+
+ async loadReleases() {
+ // If already loaded or currently loading, skip to prevent duplicate requests
+ if (this.isLoaded || this.isLoading) return;
+
+ this.isLoading = true;
+ this.error = null;
+
+ try {
+ this.releases = await invoke<GithubRelease[]>("get_github_releases");
+ this.isLoaded = true;
+ } catch (e) {
+ console.error("Failed to load releases:", e);
+ this.error = String(e);
+ } finally {
+ this.isLoading = false;
+ }
+ }
+}
+
+export const releasesState = new ReleasesState();
diff --git a/packages/ui/src/stores/settings.svelte.ts b/packages/ui/src/stores/settings.svelte.ts
new file mode 100644
index 0000000..5d20050
--- /dev/null
+++ b/packages/ui/src/stores/settings.svelte.ts
@@ -0,0 +1,570 @@
+import { invoke } from "@tauri-apps/api/core";
+import { convertFileSrc } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import type {
+ JavaCatalog,
+ JavaDownloadProgress,
+ JavaDownloadSource,
+ JavaInstallation,
+ JavaReleaseInfo,
+ LauncherConfig,
+ ModelInfo,
+ PendingJavaDownload,
+} from "../types";
+import { uiState } from "./ui.svelte";
+
+export class SettingsState {
+ settings = $state<LauncherConfig>({
+ min_memory: 1024,
+ max_memory: 2048,
+ java_path: "java",
+ width: 854,
+ height: 480,
+ download_threads: 32,
+ enable_gpu_acceleration: false,
+ enable_visual_effects: true,
+ active_effect: "constellation",
+ theme: "dark",
+ custom_background_path: undefined,
+ log_upload_service: "paste.rs",
+ pastebin_api_key: undefined,
+ assistant: {
+ enabled: true,
+ llm_provider: "ollama",
+ ollama_endpoint: "http://localhost:11434",
+ ollama_model: "llama3",
+ openai_api_key: undefined,
+ openai_endpoint: "https://api.openai.com/v1",
+ openai_model: "gpt-3.5-turbo",
+ system_prompt:
+ "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.",
+ response_language: "auto",
+ tts_enabled: false,
+ tts_provider: "disabled",
+ },
+ use_shared_caches: false,
+ keep_legacy_per_instance_storage: true,
+ feature_flags: {
+ demo_user: false,
+ quick_play_enabled: false,
+ quick_play_path: undefined,
+ quick_play_singleplayer: true,
+ quick_play_multiplayer_server: undefined,
+ },
+ });
+
+ // Convert background path to proper asset URL
+ get backgroundUrl(): string | undefined {
+ if (this.settings.custom_background_path) {
+ return convertFileSrc(this.settings.custom_background_path);
+ }
+ return undefined;
+ }
+ javaInstallations = $state<JavaInstallation[]>([]);
+ isDetectingJava = $state(false);
+
+ // Java download modal state
+ showJavaDownloadModal = $state(false);
+ selectedDownloadSource = $state<JavaDownloadSource>("adoptium");
+
+ // Java catalog state
+ javaCatalog = $state<JavaCatalog | null>(null);
+ isLoadingCatalog = $state(false);
+ catalogError = $state("");
+
+ // Version selection state
+ selectedMajorVersion = $state<number | null>(null);
+ selectedImageType = $state<"jre" | "jdk">("jre");
+ showOnlyRecommended = $state(true);
+ searchQuery = $state("");
+
+ // Download progress state
+ isDownloadingJava = $state(false);
+ downloadProgress = $state<JavaDownloadProgress | null>(null);
+ javaDownloadStatus = $state("");
+
+ // Pending downloads
+ pendingDownloads = $state<PendingJavaDownload[]>([]);
+
+ // AI Model lists
+ ollamaModels = $state<ModelInfo[]>([]);
+ openaiModels = $state<ModelInfo[]>([]);
+ isLoadingOllamaModels = $state(false);
+ isLoadingOpenaiModels = $state(false);
+ ollamaModelsError = $state("");
+ openaiModelsError = $state("");
+
+ // Config Editor state
+ showConfigEditor = $state(false);
+ rawConfigContent = $state("");
+ configFilePath = $state("");
+ configEditorError = $state("");
+
+ // Event listener cleanup
+ private progressUnlisten: UnlistenFn | null = null;
+
+ async openConfigEditor() {
+ this.configEditorError = "";
+ try {
+ const path = await invoke<string>("get_config_path");
+ const content = await invoke<string>("read_raw_config");
+ this.configFilePath = path;
+ this.rawConfigContent = content;
+ this.showConfigEditor = true;
+ } catch (e) {
+ console.error("Failed to open config editor:", e);
+ uiState.setStatus(`Failed to open config: ${e}`);
+ }
+ }
+
+ async saveRawConfig(content: string, closeAfterSave = true) {
+ try {
+ await invoke("save_raw_config", { content });
+ // Reload settings to ensure UI is in sync
+ await this.loadSettings();
+ if (closeAfterSave) {
+ this.showConfigEditor = false;
+ }
+ uiState.setStatus("Configuration saved successfully!");
+ } catch (e) {
+ console.error("Failed to save config:", e);
+ this.configEditorError = String(e);
+ }
+ }
+
+ closeConfigEditor() {
+ this.showConfigEditor = false;
+ this.rawConfigContent = "";
+ this.configEditorError = "";
+ }
+
+ // Computed: filtered releases based on selection
+ get filteredReleases(): JavaReleaseInfo[] {
+ if (!this.javaCatalog) return [];
+
+ let releases = this.javaCatalog.releases;
+
+ // Filter by major version if selected
+ if (this.selectedMajorVersion !== null) {
+ releases = releases.filter((r) => r.major_version === this.selectedMajorVersion);
+ }
+
+ // Filter by image type
+ releases = releases.filter((r) => r.image_type === this.selectedImageType);
+
+ // Filter by recommended (LTS) versions
+ if (this.showOnlyRecommended) {
+ releases = releases.filter((r) => r.is_lts);
+ }
+
+ // Filter by search query
+ if (this.searchQuery.trim()) {
+ const query = this.searchQuery.toLowerCase();
+ releases = releases.filter(
+ (r) =>
+ r.release_name.toLowerCase().includes(query) ||
+ r.version.toLowerCase().includes(query) ||
+ r.major_version.toString().includes(query),
+ );
+ }
+
+ return releases;
+ }
+
+ // Computed: available major versions for display
+ get availableMajorVersions(): number[] {
+ if (!this.javaCatalog) return [];
+ let versions = [...this.javaCatalog.available_major_versions];
+
+ // Filter by LTS if showOnlyRecommended is enabled
+ if (this.showOnlyRecommended) {
+ versions = versions.filter((v) => this.javaCatalog!.lts_versions.includes(v));
+ }
+
+ // Sort descending (newest first)
+ return versions.sort((a, b) => b - a);
+ }
+
+ // Get installation status for a release: 'installed' | 'download'
+ getInstallStatus(release: JavaReleaseInfo): "installed" | "download" {
+ // Find installed Java that matches the major version and image type (by path pattern)
+ const matchingInstallations = this.javaInstallations.filter((inst) => {
+ // Check if this is a DropOut-managed Java (path contains temurin-XX-jre/jdk pattern)
+ const pathLower = inst.path.toLowerCase();
+ const pattern = `temurin-${release.major_version}-${release.image_type}`;
+ return pathLower.includes(pattern);
+ });
+
+ // If any matching installation exists, it's installed
+ return matchingInstallations.length > 0 ? "installed" : "download";
+ }
+
+ // Computed: selected release details
+ get selectedRelease(): JavaReleaseInfo | null {
+ if (!this.javaCatalog || this.selectedMajorVersion === null) return null;
+ return (
+ this.javaCatalog.releases.find(
+ (r) =>
+ r.major_version === this.selectedMajorVersion && r.image_type === this.selectedImageType,
+ ) || null
+ );
+ }
+
+ async loadSettings() {
+ 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();
+ }
+ // Ensure custom_background_path is reactive
+ if (!this.settings.custom_background_path) {
+ this.settings.custom_background_path = undefined;
+ }
+ } catch (e) {
+ console.error("Failed to load settings:", e);
+ }
+ }
+
+ async saveSettings() {
+ try {
+ // Ensure we clean up any invalid paths before saving
+ if (this.settings.custom_background_path === "") {
+ this.settings.custom_background_path = undefined;
+ }
+
+ await invoke("save_settings", { config: this.settings });
+ uiState.setStatus("Settings saved!");
+ } catch (e) {
+ console.error("Failed to save settings:", e);
+ uiState.setStatus("Error saving settings: " + e);
+ }
+ }
+
+ async detectJava() {
+ this.isDetectingJava = true;
+ try {
+ this.javaInstallations = await invoke("detect_java");
+ if (this.javaInstallations.length === 0) {
+ uiState.setStatus("No Java installations found");
+ } else {
+ uiState.setStatus(`Found ${this.javaInstallations.length} Java installation(s)`);
+ }
+ } catch (e) {
+ console.error("Failed to detect Java:", e);
+ uiState.setStatus("Error detecting Java: " + e);
+ } finally {
+ this.isDetectingJava = false;
+ }
+ }
+
+ selectJava(path: string) {
+ this.settings.java_path = path;
+ }
+
+ async openJavaDownloadModal() {
+ this.showJavaDownloadModal = true;
+ this.javaDownloadStatus = "";
+ this.catalogError = "";
+ this.downloadProgress = null;
+
+ // Setup progress event listener
+ await this.setupProgressListener();
+
+ // Load catalog
+ await this.loadJavaCatalog(false);
+
+ // Check for pending downloads
+ await this.loadPendingDownloads();
+ }
+
+ async closeJavaDownloadModal() {
+ if (!this.isDownloadingJava) {
+ this.showJavaDownloadModal = false;
+ // Cleanup listener
+ if (this.progressUnlisten) {
+ this.progressUnlisten();
+ this.progressUnlisten = null;
+ }
+ }
+ }
+
+ private async setupProgressListener() {
+ if (this.progressUnlisten) {
+ this.progressUnlisten();
+ }
+
+ this.progressUnlisten = await listen<JavaDownloadProgress>(
+ "java-download-progress",
+ (event) => {
+ this.downloadProgress = event.payload;
+ this.javaDownloadStatus = event.payload.status;
+
+ if (event.payload.status === "Completed") {
+ this.isDownloadingJava = false;
+ setTimeout(async () => {
+ await this.detectJava();
+ uiState.setStatus(`Java installed successfully!`);
+ }, 500);
+ } else if (event.payload.status === "Error") {
+ this.isDownloadingJava = false;
+ }
+ },
+ );
+ }
+
+ async loadJavaCatalog(forceRefresh: boolean) {
+ this.isLoadingCatalog = true;
+ this.catalogError = "";
+
+ try {
+ const command = forceRefresh ? "refresh_java_catalog" : "fetch_java_catalog";
+ this.javaCatalog = await invoke<JavaCatalog>(command);
+
+ // Auto-select first LTS version
+ if (this.selectedMajorVersion === null && this.javaCatalog.lts_versions.length > 0) {
+ // Select most recent LTS (21 or highest)
+ const ltsVersions = [...this.javaCatalog.lts_versions].sort((a, b) => b - a);
+ this.selectedMajorVersion = ltsVersions[0];
+ }
+ } catch (e) {
+ console.error("Failed to load Java catalog:", e);
+ this.catalogError = `Failed to load Java catalog: ${e}`;
+ } finally {
+ this.isLoadingCatalog = false;
+ }
+ }
+
+ async refreshCatalog() {
+ await this.loadJavaCatalog(true);
+ uiState.setStatus("Java catalog refreshed");
+ }
+
+ async loadPendingDownloads() {
+ try {
+ this.pendingDownloads = await invoke<PendingJavaDownload[]>("get_pending_java_downloads");
+ } catch (e) {
+ console.error("Failed to load pending downloads:", e);
+ }
+ }
+
+ selectMajorVersion(version: number) {
+ this.selectedMajorVersion = version;
+ }
+
+ async downloadJava() {
+ if (!this.selectedRelease || !this.selectedRelease.is_available) {
+ uiState.setStatus("Selected Java version is not available for this platform");
+ return;
+ }
+
+ this.isDownloadingJava = true;
+ this.javaDownloadStatus = "Starting download...";
+ this.downloadProgress = null;
+
+ try {
+ const result: JavaInstallation = await invoke("download_adoptium_java", {
+ majorVersion: this.selectedMajorVersion,
+ imageType: this.selectedImageType,
+ customPath: null,
+ });
+
+ this.settings.java_path = result.path;
+ await this.detectJava();
+
+ setTimeout(() => {
+ this.showJavaDownloadModal = false;
+ uiState.setStatus(`Java ${this.selectedMajorVersion} is ready to use!`);
+ }, 1500);
+ } catch (e) {
+ console.error("Failed to download Java:", e);
+ this.javaDownloadStatus = `Download failed: ${e}`;
+ } finally {
+ this.isDownloadingJava = false;
+ }
+ }
+
+ async cancelDownload() {
+ try {
+ await invoke("cancel_java_download");
+ this.isDownloadingJava = false;
+ this.javaDownloadStatus = "Download cancelled";
+ this.downloadProgress = null;
+ await this.loadPendingDownloads();
+ } catch (e) {
+ console.error("Failed to cancel download:", e);
+ }
+ }
+
+ async resumeDownloads() {
+ if (this.pendingDownloads.length === 0) return;
+
+ this.isDownloadingJava = true;
+ this.javaDownloadStatus = "Resuming download...";
+
+ try {
+ const installed = await invoke<JavaInstallation[]>("resume_java_downloads");
+ if (installed.length > 0) {
+ this.settings.java_path = installed[0].path;
+ await this.detectJava();
+ uiState.setStatus(`Resumed and installed ${installed.length} Java version(s)`);
+ }
+ await this.loadPendingDownloads();
+ } catch (e) {
+ console.error("Failed to resume downloads:", e);
+ this.javaDownloadStatus = `Resume failed: ${e}`;
+ } finally {
+ this.isDownloadingJava = false;
+ }
+ }
+
+ // Format bytes to human readable
+ formatBytes(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
+ }
+
+ // Format seconds to human readable
+ formatTime(seconds: number): string {
+ 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`;
+ }
+
+ // Format date string
+ formatDate(dateStr: string | null): string {
+ if (!dateStr) return "--";
+ try {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ year: "2-digit",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ } catch {
+ return "--";
+ }
+ }
+
+ // Legacy compatibility
+ get availableJavaVersions(): number[] {
+ return this.availableMajorVersions;
+ }
+
+ // AI Model loading methods
+ async loadOllamaModels() {
+ this.isLoadingOllamaModels = true;
+ this.ollamaModelsError = "";
+
+ try {
+ const models = await invoke<ModelInfo[]>("list_ollama_models", {
+ endpoint: this.settings.assistant.ollama_endpoint,
+ });
+ this.ollamaModels = models;
+
+ // If no model is selected or selected model isn't available, select the first one
+ if (models.length > 0) {
+ const currentModel = this.settings.assistant.ollama_model;
+ const modelExists = models.some((m) => m.id === currentModel);
+ if (!modelExists) {
+ this.settings.assistant.ollama_model = models[0].id;
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load Ollama models:", e);
+ this.ollamaModelsError = String(e);
+ this.ollamaModels = [];
+ } finally {
+ this.isLoadingOllamaModels = false;
+ }
+ }
+
+ async loadOpenaiModels() {
+ if (!this.settings.assistant.openai_api_key) {
+ this.openaiModelsError = "API key required";
+ this.openaiModels = [];
+ return;
+ }
+
+ this.isLoadingOpenaiModels = true;
+ this.openaiModelsError = "";
+
+ try {
+ const models = await invoke<ModelInfo[]>("list_openai_models");
+ this.openaiModels = models;
+
+ // If no model is selected or selected model isn't available, select the first one
+ if (models.length > 0) {
+ const currentModel = this.settings.assistant.openai_model;
+ const modelExists = models.some((m) => m.id === currentModel);
+ if (!modelExists) {
+ this.settings.assistant.openai_model = models[0].id;
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load OpenAI models:", e);
+ this.openaiModelsError = String(e);
+ this.openaiModels = [];
+ } finally {
+ this.isLoadingOpenaiModels = false;
+ }
+ }
+
+ // Computed: get model options for current provider
+ get currentModelOptions(): { value: string; label: string; details?: string }[] {
+ const provider = this.settings.assistant.llm_provider;
+
+ if (provider === "ollama") {
+ if (this.ollamaModels.length === 0) {
+ // Return fallback options if no models loaded
+ return [
+ { value: "llama3", label: "Llama 3" },
+ { value: "llama3.1", label: "Llama 3.1" },
+ { value: "llama3.2", label: "Llama 3.2" },
+ { value: "mistral", label: "Mistral" },
+ { value: "gemma2", label: "Gemma 2" },
+ { value: "qwen2.5", label: "Qwen 2.5" },
+ { value: "phi3", label: "Phi-3" },
+ { value: "codellama", label: "Code Llama" },
+ ];
+ }
+ return this.ollamaModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.size ? `${m.size}${m.details ? ` - ${m.details}` : ""}` : m.details,
+ }));
+ } else if (provider === "openai") {
+ if (this.openaiModels.length === 0) {
+ // Return fallback options if no models loaded
+ return [
+ { value: "gpt-4o", label: "GPT-4o" },
+ { value: "gpt-4o-mini", label: "GPT-4o Mini" },
+ { value: "gpt-4-turbo", label: "GPT-4 Turbo" },
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
+ ];
+ }
+ return this.openaiModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.details,
+ }));
+ }
+
+ return [];
+ }
+}
+
+export const settingsState = new SettingsState();
diff --git a/packages/ui/src/stores/ui.svelte.ts b/packages/ui/src/stores/ui.svelte.ts
new file mode 100644
index 0000000..e88f6b4
--- /dev/null
+++ b/packages/ui/src/stores/ui.svelte.ts
@@ -0,0 +1,32 @@
+import { type ViewType } from "../types";
+
+export class UIState {
+ currentView: ViewType = $state("home");
+ status = $state("Ready");
+ showConsole = $state(false);
+ appVersion = $state("...");
+
+ private statusTimeout: ReturnType<typeof setTimeout> | null = null;
+
+ setStatus(msg: string) {
+ if (this.statusTimeout) clearTimeout(this.statusTimeout);
+
+ this.status = msg;
+
+ if (msg !== "Ready") {
+ this.statusTimeout = setTimeout(() => {
+ this.status = "Ready";
+ }, 5000);
+ }
+ }
+
+ toggleConsole() {
+ this.showConsole = !this.showConsole;
+ }
+
+ setView(view: ViewType) {
+ this.currentView = view;
+ }
+}
+
+export const uiState = new UIState();
diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts
new file mode 100644
index 0000000..b4412b8
--- /dev/null
+++ b/packages/ui/src/types/index.ts
@@ -0,0 +1,232 @@
+export type ViewType = "home" | "versions" | "settings" | "guide" | "instances";
+
+export interface Version {
+ id: string;
+ type: string;
+ url: string;
+ time: string;
+ releaseTime: string;
+ javaVersion?: number; // Java major version requirement (e.g., 8, 17, 21)
+ isInstalled?: boolean; // Whether this version is installed locally
+}
+
+export interface Account {
+ type: "Offline" | "Microsoft";
+ username: string;
+ uuid: string;
+ access_token?: string;
+ refresh_token?: string;
+ expires_at?: number; // Unix timestamp for Microsoft accounts
+}
+
+export interface DeviceCodeResponse {
+ user_code: string;
+ device_code: string;
+ verification_uri: string;
+ expires_in: number;
+ interval: number;
+ message?: string;
+}
+
+export interface AssistantConfig {
+ enabled: boolean;
+ llm_provider: "ollama" | "openai";
+ // Ollama settings
+ ollama_endpoint: string;
+ ollama_model: string;
+ // OpenAI settings
+ openai_api_key?: string;
+ openai_endpoint: string;
+ openai_model: string;
+ // Common settings
+ system_prompt: string;
+ response_language: string;
+ // TTS settings
+ tts_enabled: boolean;
+ tts_provider: string;
+}
+
+export interface ModelInfo {
+ id: string;
+ name: string;
+ size?: string;
+ details?: string;
+}
+
+export interface LauncherConfig {
+ min_memory: number;
+ max_memory: number;
+ 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;
+ log_upload_service: "paste.rs" | "pastebin.com";
+ pastebin_api_key?: string;
+ assistant: AssistantConfig;
+ // Storage management
+ use_shared_caches: boolean;
+ keep_legacy_per_instance_storage: boolean;
+ // Feature-gated argument flags
+ feature_flags: FeatureFlags;
+}
+
+export interface FeatureFlags {
+ demo_user: boolean;
+ quick_play_enabled: boolean;
+ quick_play_path?: string;
+ quick_play_singleplayer: boolean;
+ quick_play_multiplayer_server?: string;
+}
+
+export interface JavaInstallation {
+ path: string;
+ version: string;
+ is_64bit: boolean;
+}
+
+export interface JavaDownloadInfo {
+ version: string;
+ release_name: string;
+ download_url: string;
+ file_name: string;
+ file_size: number;
+ checksum: string | null;
+ image_type: string;
+}
+
+export interface JavaReleaseInfo {
+ major_version: number;
+ image_type: string;
+ version: string;
+ release_name: string;
+ release_date: string | null;
+ file_size: number;
+ checksum: string | null;
+ download_url: string;
+ is_lts: boolean;
+ is_available: boolean;
+ architecture: string;
+}
+
+export interface JavaCatalog {
+ releases: JavaReleaseInfo[];
+ available_major_versions: number[];
+ lts_versions: number[];
+ cached_at: number;
+}
+
+export interface JavaDownloadProgress {
+ file_name: string;
+ downloaded_bytes: number;
+ total_bytes: number;
+ speed_bytes_per_sec: number;
+ eta_seconds: number;
+ status: string;
+ percentage: number;
+}
+
+export interface PendingJavaDownload {
+ major_version: number;
+ image_type: string;
+ download_url: string;
+ file_name: string;
+ file_size: number;
+ checksum: string | null;
+ install_path: string;
+ created_at: number;
+}
+
+export type JavaDownloadSource = "adoptium" | "mojang" | "azul";
+
+// ==================== 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";
+
+// ==================== Instance Types ====================
+
+export interface Instance {
+ id: string;
+ name: string;
+ game_dir: string;
+ version_id?: string;
+ created_at: number;
+ last_played?: number;
+ icon_path?: string;
+ notes?: string;
+ mod_loader?: string;
+ mod_loader_version?: string;
+ jvm_args_override?: string;
+ memory_override?: MemoryOverride;
+}
+
+export interface MemoryOverride {
+ min: number; // MB
+ max: number; // MB
+}
+
+export interface FileInfo {
+ name: string;
+ path: string;
+ is_directory: boolean;
+ size: number;
+ modified: number;
+}