aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui/src/components')
-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/components/bottom-bar.tsx231
-rw-r--r--packages/ui/src/components/config-editor.tsx111
-rw-r--r--packages/ui/src/components/download-monitor.tsx62
-rw-r--r--packages/ui/src/components/game-console.tsx290
-rw-r--r--packages/ui/src/components/instance-creation-modal.tsx552
-rw-r--r--packages/ui/src/components/instance-editor-modal.tsx548
-rw-r--r--packages/ui/src/components/login-modal.tsx188
-rw-r--r--packages/ui/src/components/particle-background.tsx63
-rw-r--r--packages/ui/src/components/sidebar.tsx185
-rw-r--r--packages/ui/src/components/ui/avatar.tsx107
-rw-r--r--packages/ui/src/components/ui/badge.tsx52
-rw-r--r--packages/ui/src/components/ui/button.tsx56
-rw-r--r--packages/ui/src/components/ui/card.tsx103
-rw-r--r--packages/ui/src/components/ui/checkbox.tsx27
-rw-r--r--packages/ui/src/components/ui/dialog.tsx155
-rw-r--r--packages/ui/src/components/ui/dropdown-menu.tsx269
-rw-r--r--packages/ui/src/components/ui/field.tsx238
-rw-r--r--packages/ui/src/components/ui/input.tsx20
-rw-r--r--packages/ui/src/components/ui/label.tsx19
-rw-r--r--packages/ui/src/components/ui/scroll-area.tsx53
-rw-r--r--packages/ui/src/components/ui/select.tsx199
-rw-r--r--packages/ui/src/components/ui/separator.tsx25
-rw-r--r--packages/ui/src/components/ui/sonner.tsx43
-rw-r--r--packages/ui/src/components/ui/spinner.tsx10
-rw-r--r--packages/ui/src/components/ui/switch.tsx32
-rw-r--r--packages/ui/src/components/ui/tabs.tsx80
-rw-r--r--packages/ui/src/components/ui/textarea.tsx18
-rw-r--r--packages/ui/src/components/user-avatar.tsx23
43 files changed, 3759 insertions, 5194 deletions
diff --git a/packages/ui/src/components/AssistantView.svelte b/packages/ui/src/components/AssistantView.svelte
deleted file mode 100644
index 54509a5..0000000
--- a/packages/ui/src/components/AssistantView.svelte
+++ /dev/null
@@ -1,436 +0,0 @@
-<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
deleted file mode 100644
index 19cf35d..0000000
--- a/packages/ui/src/components/BottomBar.svelte
+++ /dev/null
@@ -1,250 +0,0 @@
-<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
deleted file mode 100644
index dd866ee..0000000
--- a/packages/ui/src/components/ConfigEditorModal.svelte
+++ /dev/null
@@ -1,369 +0,0 @@
-<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
deleted file mode 100644
index 0767471..0000000
--- a/packages/ui/src/components/CustomSelect.svelte
+++ /dev/null
@@ -1,173 +0,0 @@
-<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
deleted file mode 100644
index 573d9da..0000000
--- a/packages/ui/src/components/HomeView.svelte
+++ /dev/null
@@ -1,271 +0,0 @@
-<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
deleted file mode 100644
index c54cb98..0000000
--- a/packages/ui/src/components/InstanceCreationModal.svelte
+++ /dev/null
@@ -1,485 +0,0 @@
-<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
deleted file mode 100644
index 0856d93..0000000
--- a/packages/ui/src/components/InstanceEditorModal.svelte
+++ /dev/null
@@ -1,439 +0,0 @@
-<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
deleted file mode 100644
index 5334f9e..0000000
--- a/packages/ui/src/components/InstancesView.svelte
+++ /dev/null
@@ -1,259 +0,0 @@
-<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
deleted file mode 100644
index 1886cd9..0000000
--- a/packages/ui/src/components/LoginModal.svelte
+++ /dev/null
@@ -1,126 +0,0 @@
-<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
deleted file mode 100644
index 50caa8c..0000000
--- a/packages/ui/src/components/ModLoaderSelector.svelte
+++ /dev/null
@@ -1,455 +0,0 @@
-<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
deleted file mode 100644
index 7644b1a..0000000
--- a/packages/ui/src/components/ParticleBackground.svelte
+++ /dev/null
@@ -1,70 +0,0 @@
-<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
deleted file mode 100644
index 0020506..0000000
--- a/packages/ui/src/components/SettingsView.svelte
+++ /dev/null
@@ -1,1217 +0,0 @@
-<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
deleted file mode 100644
index 83f4ac6..0000000
--- a/packages/ui/src/components/Sidebar.svelte
+++ /dev/null
@@ -1,91 +0,0 @@
-<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
deleted file mode 100644
index 4c981c7..0000000
--- a/packages/ui/src/components/StatusToast.svelte
+++ /dev/null
@@ -1,42 +0,0 @@
-<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
deleted file mode 100644
index f1474d9..0000000
--- a/packages/ui/src/components/VersionsView.svelte
+++ /dev/null
@@ -1,511 +0,0 @@
-<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/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx
new file mode 100644
index 0000000..32eb852
--- /dev/null
+++ b/packages/ui/src/components/bottom-bar.tsx
@@ -0,0 +1,231 @@
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { Play, User } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { listInstalledVersions, startGame } from "@/client";
+import { cn } from "@/lib/utils";
+import { useAuthStore } from "@/models/auth";
+import { useInstancesStore } from "@/models/instances";
+import { useGameStore } from "@/stores/game-store";
+import { LoginModal } from "./login-modal";
+import { Button } from "./ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./ui/select";
+
+interface InstalledVersion {
+ id: string;
+ type: string;
+}
+
+export function BottomBar() {
+ const authStore = useAuthStore();
+ const gameStore = useGameStore();
+ const instancesStore = useInstancesStore();
+
+ const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
+ const [installedVersions, setInstalledVersions] = useState<
+ InstalledVersion[]
+ >([]);
+ const [isLoadingVersions, setIsLoadingVersions] = useState(true);
+ const [showLoginModal, setShowLoginModal] = useState(false);
+
+ const loadInstalledVersions = useCallback(async () => {
+ if (!instancesStore.activeInstance) {
+ setInstalledVersions([]);
+ setIsLoadingVersions(false);
+ return;
+ }
+
+ setIsLoadingVersions(true);
+ try {
+ const versions = await listInstalledVersions(
+ instancesStore.activeInstance.id,
+ );
+
+ const installed = versions || [];
+ setInstalledVersions(installed);
+
+ // If no version is selected but we have installed versions, select the first one
+ if (!gameStore.selectedVersion && installed.length > 0) {
+ gameStore.setSelectedVersion(installed[0].id);
+ }
+ } catch (error) {
+ console.error("Failed to load installed versions:", error);
+ } finally {
+ setIsLoadingVersions(false);
+ }
+ }, [
+ instancesStore.activeInstance,
+ gameStore.selectedVersion,
+ gameStore.setSelectedVersion,
+ ]);
+
+ useEffect(() => {
+ loadInstalledVersions();
+
+ // Listen for backend events that should refresh installed versions.
+ let unlistenDownload: UnlistenFn | null = null;
+ let unlistenVersionDeleted: UnlistenFn | null = null;
+
+ (async () => {
+ try {
+ unlistenDownload = await listen("download-complete", () => {
+ loadInstalledVersions();
+ });
+ } catch (err) {
+ // best-effort: do not break UI if listening fails
+ // eslint-disable-next-line no-console
+ console.warn("Failed to attach download-complete listener:", err);
+ }
+
+ try {
+ unlistenVersionDeleted = await listen("version-deleted", () => {
+ loadInstalledVersions();
+ });
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn("Failed to attach version-deleted listener:", err);
+ }
+ })();
+
+ return () => {
+ try {
+ if (unlistenDownload) unlistenDownload();
+ } catch {
+ // ignore
+ }
+ try {
+ if (unlistenVersionDeleted) unlistenVersionDeleted();
+ } catch {
+ // ignore
+ }
+ };
+ }, [loadInstalledVersions]);
+
+ const handleStartGame = async () => {
+ if (!selectedVersion) {
+ toast.info("Please select a version!");
+ return;
+ }
+
+ if (!instancesStore.activeInstance) {
+ toast.info("Please select an instance first!");
+ return;
+ }
+ // await gameStore.startGame(
+ // authStore.currentAccount,
+ // authStore.openLoginModal,
+ // instancesStore.activeInstanceId,
+ // uiStore.setView,
+ // );
+ await startGame(instancesStore.activeInstance?.id, selectedVersion);
+ };
+
+ const getVersionTypeColor = (type: string) => {
+ switch (type) {
+ case "release":
+ return "bg-emerald-500";
+ case "snapshot":
+ return "bg-amber-500";
+ case "old_beta":
+ return "bg-rose-500";
+ case "old_alpha":
+ return "bg-violet-500";
+ default:
+ return "bg-gray-500";
+ }
+ };
+
+ const versionOptions = useMemo(
+ () =>
+ installedVersions.map((v) => ({
+ label: `${v.id}${v.type !== "release" ? ` (${v.type})` : ""}`,
+ value: v.id,
+ type: v.type,
+ })),
+ [installedVersions],
+ );
+
+ return (
+ <div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/30 via-transparent to-transparent p-4 z-10">
+ <div className="max-w-7xl mx-auto">
+ <div className="flex items-center justify-between bg-white/5 dark:bg-black/20 backdrop-blur-xl border border-white/10 dark:border-white/5 p-3 shadow-lg">
+ <div className="flex items-center gap-4">
+ <div className="flex flex-col">
+ <span className="text-xs font-mono text-zinc-400 uppercase tracking-wider">
+ Active Instance
+ </span>
+ <span className="text-sm font-medium text-white">
+ {instancesStore.activeInstance?.name || "No instance selected"}
+ </span>
+ </div>
+
+ <Select
+ items={versionOptions}
+ onValueChange={setSelectedVersion}
+ disabled={isLoadingVersions}
+ >
+ <SelectTrigger className="max-w-48">
+ <SelectValue
+ placeholder={
+ isLoadingVersions
+ ? "Loading versions..."
+ : "Please select a version"
+ }
+ />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {versionOptions.map((item) => (
+ <SelectItem
+ key={item.value}
+ value={item.value}
+ className={getVersionTypeColor(item.type)}
+ >
+ {item.label}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="flex items-center gap-3">
+ {authStore.account ? (
+ <Button
+ className={cn(
+ "px-4 py-2 shadow-xl",
+ "bg-emerald-600! hover:bg-emerald-500!",
+ )}
+ size="lg"
+ onClick={handleStartGame}
+ >
+ <Play />
+ Start
+ </Button>
+ ) : (
+ <Button
+ className="px-4 py-2"
+ size="lg"
+ onClick={() => setShowLoginModal(true)}
+ >
+ <User /> Login
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <LoginModal
+ open={showLoginModal}
+ onOpenChange={() => setShowLoginModal(false)}
+ />
+ </div>
+ );
+}
diff --git a/packages/ui/src/components/config-editor.tsx b/packages/ui/src/components/config-editor.tsx
new file mode 100644
index 0000000..129b8f7
--- /dev/null
+++ b/packages/ui/src/components/config-editor.tsx
@@ -0,0 +1,111 @@
+import type React from "react";
+import { useEffect, useState } from "react";
+import { type ZodType, z } from "zod";
+import { useSettingsStore } from "@/models/settings";
+import type { LauncherConfig } from "@/types";
+import { Button } from "./ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "./ui/dialog";
+import { FieldError } from "./ui/field";
+import { Spinner } from "./ui/spinner";
+import { Textarea } from "./ui/textarea";
+
+const launcherConfigSchema: ZodType<LauncherConfig> = z.object({
+ minMemory: z.number(),
+ maxMemory: z.number(),
+ javaPath: z.string(),
+ width: z.number(),
+ height: z.number(),
+ downloadThreads: z.number(),
+ customBackgroundPath: z.string().nullable(),
+ enableGpuAcceleration: z.boolean(),
+ enableVisualEffects: z.boolean(),
+ activeEffect: z.string(),
+ theme: z.string(),
+ logUploadService: z.string(),
+ pastebinApiKey: z.string().nullable(),
+ assistant: z.any(), // TODO: AssistantConfig schema
+ useSharedCaches: z.boolean(),
+ keepLegacyPerInstanceStorage: z.boolean(),
+ featureFlags: z.any(), // TODO: FeatureFlags schema
+});
+
+export interface ConfigEditorProps
+ extends Omit<React.ComponentPropsWithoutRef<typeof Dialog>, "onOpenChange"> {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function ConfigEditor({ onOpenChange, ...props }: ConfigEditorProps) {
+ const settings = useSettingsStore();
+
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
+ const [rawConfigContent, setRawConfigContent] = useState(
+ JSON.stringify(settings.config, null, 2),
+ );
+ const [isSaving, setIsSaving] = useState(false);
+
+ useEffect(() => {
+ setRawConfigContent(JSON.stringify(settings.config, null, 2));
+ }, [settings.config]);
+
+ const handleSave = async () => {
+ setIsSaving(true);
+ setErrorMessage(null);
+ try {
+ const validatedConfig = launcherConfigSchema.parse(
+ JSON.parse(rawConfigContent),
+ );
+ settings.config = validatedConfig;
+ await settings.save();
+ onOpenChange?.(false);
+ } catch (error) {
+ setErrorMessage(error instanceof Error ? error.message : String(error));
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+ <Dialog onOpenChange={onOpenChange} {...props}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Edit Configuration</DialogTitle>
+ <DialogDescription>
+ Edit the raw JSON configuration file.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Textarea
+ value={rawConfigContent}
+ onChange={(e) => setRawConfigContent(e.target.value)}
+ className="font-mono text-sm h-100 resize-none"
+ spellCheck={false}
+ aria-invalid={!!errorMessage}
+ />
+
+ {errorMessage && <FieldError errors={[{ message: errorMessage }]} />}
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange?.(false)}
+ disabled={isSaving}
+ >
+ Cancel
+ </Button>
+ <Button onClick={handleSave} disabled={isSaving}>
+ {isSaving && <Spinner />}
+ Save Changes
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/packages/ui/src/components/download-monitor.tsx b/packages/ui/src/components/download-monitor.tsx
new file mode 100644
index 0000000..f3902d9
--- /dev/null
+++ b/packages/ui/src/components/download-monitor.tsx
@@ -0,0 +1,62 @@
+import { X } from "lucide-react";
+import { useState } from "react";
+
+export function DownloadMonitor() {
+ const [isVisible, setIsVisible] = useState(true);
+
+ if (!isVisible) return null;
+
+ return (
+ <div className="bg-zinc-900/80 backdrop-blur-md border border-zinc-700 rounded-lg shadow-2xl overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between px-4 py-3 bg-zinc-800/50 border-b border-zinc-700">
+ <div className="flex items-center gap-2">
+ <div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
+ <span className="text-sm font-medium text-white">Downloads</span>
+ </div>
+ <button
+ type="button"
+ onClick={() => setIsVisible(false)}
+ className="text-zinc-400 hover:text-white transition-colors p-1"
+ >
+ <X size={16} />
+ </button>
+ </div>
+
+ {/* Content */}
+ <div className="p-4">
+ <div className="space-y-3">
+ {/* Download Item */}
+ <div className="space-y-1">
+ <div className="flex justify-between text-xs">
+ <span className="text-zinc-300">Minecraft 1.20.4</span>
+ <span className="text-zinc-400">65%</span>
+ </div>
+ <div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
+ <div
+ className="h-full bg-emerald-500 rounded-full transition-all duration-300"
+ style={{ width: "65%" }}
+ ></div>
+ </div>
+ <div className="flex justify-between text-[10px] text-zinc-500">
+ <span>142 MB / 218 MB</span>
+ <span>2.1 MB/s • 36s remaining</span>
+ </div>
+ </div>
+
+ {/* Download Item */}
+ <div className="space-y-1">
+ <div className="flex justify-between text-xs">
+ <span className="text-zinc-300">Java 17</span>
+ <span className="text-zinc-400">100%</span>
+ </div>
+ <div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
+ <div className="h-full bg-emerald-500 rounded-full"></div>
+ </div>
+ <div className="text-[10px] text-emerald-400">Completed</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui/src/components/game-console.tsx b/packages/ui/src/components/game-console.tsx
new file mode 100644
index 0000000..6980c8c
--- /dev/null
+++ b/packages/ui/src/components/game-console.tsx
@@ -0,0 +1,290 @@
+import { Copy, Download, Filter, Search, Trash2, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { useLogsStore } from "@/stores/logs-store";
+import { useUIStore } from "@/stores/ui-store";
+
+export function GameConsole() {
+ const uiStore = useUIStore();
+ const logsStore = useLogsStore();
+
+ const [searchTerm, setSearchTerm] = useState("");
+ const [selectedLevels, setSelectedLevels] = useState<Set<string>>(
+ new Set(["info", "warn", "error", "debug", "fatal"]),
+ );
+ const [autoScroll, setAutoScroll] = useState(true);
+ const consoleEndRef = useRef<HTMLDivElement>(null);
+ const logsContainerRef = useRef<HTMLDivElement>(null);
+
+ const levelColors: Record<string, string> = {
+ info: "text-blue-400",
+ warn: "text-amber-400",
+ error: "text-red-400",
+ debug: "text-purple-400",
+ fatal: "text-rose-400",
+ };
+
+ const levelBgColors: Record<string, string> = {
+ info: "bg-blue-400/10",
+ warn: "bg-amber-400/10",
+ error: "bg-red-400/10",
+ debug: "bg-purple-400/10",
+ fatal: "bg-rose-400/10",
+ };
+
+ // Filter logs based on search term and selected levels
+ const filteredLogs = logsStore.logs.filter((log) => {
+ const matchesSearch =
+ searchTerm === "" ||
+ log.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ log.source.toLowerCase().includes(searchTerm.toLowerCase());
+
+ const matchesLevel = selectedLevels.has(log.level);
+
+ return matchesSearch && matchesLevel;
+ });
+
+ // Auto-scroll to bottom when new logs arrive or autoScroll is enabled
+ useEffect(() => {
+ if (autoScroll && consoleEndRef.current && filteredLogs.length > 0) {
+ consoleEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [filteredLogs, autoScroll]);
+
+ // Handle keyboard shortcuts
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Ctrl/Cmd + K to focus search
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
+ e.preventDefault();
+ // Focus search input
+ const searchInput = document.querySelector(
+ 'input[type="text"]',
+ ) as HTMLInputElement;
+ if (searchInput) searchInput.focus();
+ }
+ // Escape to close console
+ if (e.key === "Escape") {
+ uiStore.toggleConsole();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [uiStore.toggleConsole]);
+
+ const toggleLevel = (level: string) => {
+ const newLevels = new Set(selectedLevels);
+ if (newLevels.has(level)) {
+ newLevels.delete(level);
+ } else {
+ newLevels.add(level);
+ }
+ setSelectedLevels(newLevels);
+ };
+
+ const handleCopyAll = () => {
+ const logsText = logsStore.exportLogs(filteredLogs);
+ navigator.clipboard.writeText(logsText);
+ };
+
+ const handleExport = () => {
+ const logsText = logsStore.exportLogs(filteredLogs);
+ const blob = new Blob([logsText], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `dropout_logs_${new Date().toISOString().replace(/[:.]/g, "-")}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ const handleClear = () => {
+ logsStore.clear();
+ };
+
+ return (
+ <>
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 border-b border-zinc-700 bg-[#252526]">
+ <div className="flex items-center gap-3">
+ <h2 className="text-lg font-bold text-white">Game Console</h2>
+ <div className="flex items-center gap-1">
+ <span className="text-xs text-zinc-400">Logs:</span>
+ <span className="text-xs font-medium text-emerald-400">
+ {filteredLogs.length}
+ </span>
+ <span className="text-xs text-zinc-400">/</span>
+ <span className="text-xs text-zinc-400">
+ {logsStore.logs.length}
+ </span>
+ </div>
+ </div>
+ <button
+ type="button"
+ onClick={() => uiStore.toggleConsole()}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ >
+ <X size={20} />
+ </button>
+ </div>
+
+ {/* Toolbar */}
+ <div className="flex items-center gap-3 p-3 border-b border-zinc-700 bg-[#2D2D30]">
+ {/* Search */}
+ <div className="relative flex-1">
+ <Search
+ className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-500"
+ size={16}
+ />
+ <input
+ type="text"
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ placeholder="Search logs..."
+ className="w-full pl-10 pr-4 py-2 bg-[#3E3E42] border border-zinc-600 rounded text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
+ />
+ {searchTerm && (
+ <button
+ type="button"
+ onClick={() => setSearchTerm("")}
+ className="absolute right-3 top-1/2 transform -translate-y-1/2 text-zinc-400 hover:text-white"
+ >
+ ×
+ </button>
+ )}
+ </div>
+
+ {/* Level Filters */}
+ <div className="flex items-center gap-1">
+ {Object.entries(levelColors).map(([level, colorClass]) => (
+ <button
+ type="button"
+ key={level}
+ onClick={() => toggleLevel(level)}
+ className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
+ selectedLevels.has(level)
+ ? `${levelBgColors[level]} ${colorClass}`
+ : "bg-[#3E3E42] text-zinc-400 hover:text-white"
+ }`}
+ >
+ {level.toUpperCase()}
+ </button>
+ ))}
+ </div>
+
+ {/* Actions */}
+ <div className="flex items-center gap-1">
+ <button
+ type="button"
+ onClick={handleCopyAll}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ title="Copy all logs"
+ >
+ <Copy size={16} />
+ </button>
+ <button
+ type="button"
+ onClick={handleExport}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ title="Export logs"
+ >
+ <Download size={16} />
+ </button>
+ <button
+ type="button"
+ onClick={handleClear}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ title="Clear logs"
+ >
+ <Trash2 size={16} />
+ </button>
+ </div>
+
+ {/* Auto-scroll Toggle */}
+ <div className="flex items-center gap-2 pl-2 border-l border-zinc-700">
+ <label className="inline-flex items-center cursor-pointer">
+ <input
+ type="checkbox"
+ checked={autoScroll}
+ onChange={(e) => setAutoScroll(e.target.checked)}
+ className="sr-only peer"
+ />
+ <div className="relative w-9 h-5 bg-zinc-700 peer-focus:outline-none peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
+ <span className="ml-2 text-xs text-zinc-400">Auto-scroll</span>
+ </label>
+ </div>
+ </div>
+
+ {/* Logs Container */}
+ <div
+ ref={logsContainerRef}
+ className="flex-1 overflow-y-auto font-mono text-sm bg-[#1E1E1E]"
+ style={{ fontFamily: "'Cascadia Code', 'Consolas', monospace" }}
+ >
+ {filteredLogs.length === 0 ? (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center text-zinc-500">
+ <Filter className="mx-auto mb-2" size={24} />
+ <p>No logs match the current filters</p>
+ </div>
+ </div>
+ ) : (
+ <div className="p-4 space-y-1">
+ {filteredLogs.map((log) => (
+ <div
+ key={log.id}
+ className="group hover:bg-white/5 p-2 rounded transition-colors"
+ >
+ <div className="flex items-start gap-3">
+ <div
+ className={`px-2 py-0.5 rounded text-xs font-medium ${levelBgColors[log.level]} ${levelColors[log.level]}`}
+ >
+ {log.level.toUpperCase()}
+ </div>
+ <div className="text-zinc-400 text-xs shrink-0">
+ {log.timestamp}
+ </div>
+ <div className="text-amber-300 text-xs shrink-0">
+ [{log.source}]
+ </div>
+ <div className="text-gray-300 flex-1">{log.message}</div>
+ </div>
+ </div>
+ ))}
+ <div ref={consoleEndRef} />
+ </div>
+ )}
+ </div>
+
+ {/* Footer */}
+ <div className="flex items-center justify-between p-3 border-t border-zinc-700 bg-[#252526] text-xs text-zinc-400">
+ <div className="flex items-center gap-4">
+ <div>
+ <span>Total: </span>
+ <span className="text-white">{logsStore.logs.length}</span>
+ <span> | Filtered: </span>
+ <span className="text-emerald-400">{filteredLogs.length}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <kbd className="px-1.5 py-0.5 bg-[#3E3E42] rounded text-xs">
+ Ctrl+K
+ </kbd>
+ <span>to search</span>
+ </div>
+ </div>
+ <div>
+ <span>Updated: </span>
+ <span>
+ {new Date().toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ })}
+ </span>
+ </div>
+ </div>
+ </>
+ );
+}
diff --git a/packages/ui/src/components/instance-creation-modal.tsx b/packages/ui/src/components/instance-creation-modal.tsx
new file mode 100644
index 0000000..8a2b1b4
--- /dev/null
+++ b/packages/ui/src/components/instance-creation-modal.tsx
@@ -0,0 +1,552 @@
+import { invoke } from "@tauri-apps/api/core";
+import { Loader2, Search } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { useInstancesStore } from "@/models/instances";
+import { useGameStore } from "@/stores/game-store";
+import type { Version } from "@/types/bindings/manifest";
+import type { FabricLoaderEntry } from "../types/bindings/fabric";
+import type { ForgeVersion as ForgeVersionEntry } from "../types/bindings/forge";
+import type { Instance } from "../types/bindings/instance";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function InstanceCreationModal({ open, onOpenChange }: Props) {
+ const gameStore = useGameStore();
+ const instancesStore = useInstancesStore();
+
+ // Steps: 1 = name, 2 = version, 3 = mod loader
+ const [step, setStep] = useState<number>(1);
+
+ // Step 1
+ const [instanceName, setInstanceName] = useState<string>("");
+
+ // Step 2
+ const [versionSearch, setVersionSearch] = useState<string>("");
+ const [versionFilter, setVersionFilter] = useState<
+ "all" | "release" | "snapshot"
+ >("release");
+ const [selectedVersionUI, setSelectedVersionUI] = useState<Version | null>(
+ null,
+ );
+
+ // Step 3
+ const [modLoaderType, setModLoaderType] = useState<
+ "vanilla" | "fabric" | "forge"
+ >("vanilla");
+ const [fabricLoaders, setFabricLoaders] = useState<FabricLoaderEntry[]>([]);
+ const [forgeVersions, setForgeVersions] = useState<ForgeVersionEntry[]>([]);
+ const [selectedFabricLoader, setSelectedFabricLoader] = useState<string>("");
+ const [selectedForgeLoader, setSelectedForgeLoader] = useState<string>("");
+ const [loadingLoaders, setLoadingLoaders] = useState(false);
+
+ const loadModLoaders = useCallback(async () => {
+ if (!selectedVersionUI) return;
+ setLoadingLoaders(true);
+ setFabricLoaders([]);
+ setForgeVersions([]);
+ try {
+ if (modLoaderType === "fabric") {
+ const loaders = await invoke<FabricLoaderEntry[]>(
+ "get_fabric_loaders_for_version",
+ {
+ gameVersion: selectedVersionUI.id,
+ },
+ );
+ setFabricLoaders(loaders || []);
+ if (loaders && loaders.length > 0) {
+ setSelectedFabricLoader(loaders[0].loader.version);
+ } else {
+ setSelectedFabricLoader("");
+ }
+ } else if (modLoaderType === "forge") {
+ const versions = await invoke<ForgeVersionEntry[]>(
+ "get_forge_versions_for_game",
+ {
+ gameVersion: selectedVersionUI.id,
+ },
+ );
+ setForgeVersions(versions || []);
+ if (versions && versions.length > 0) {
+ // Binding `ForgeVersion` uses `version` (not `id`) — use `.version` here.
+ setSelectedForgeLoader(versions[0].version);
+ } else {
+ setSelectedForgeLoader("");
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load mod loaders:", e);
+ toast.error("Failed to fetch mod loader versions");
+ } finally {
+ setLoadingLoaders(false);
+ }
+ }, [modLoaderType, selectedVersionUI]);
+
+ // When entering step 3 and a base version exists, fetch loaders if needed
+ useEffect(() => {
+ if (step === 3 && modLoaderType !== "vanilla" && selectedVersionUI) {
+ loadModLoaders();
+ }
+ }, [step, modLoaderType, selectedVersionUI, loadModLoaders]);
+
+ // Creating state
+ const [creating, setCreating] = useState(false);
+ const [errorMessage, setErrorMessage] = useState<string>("");
+
+ // Derived filtered versions
+ const filteredVersions = useMemo(() => {
+ const all = gameStore.versions || [];
+ let list = all.slice();
+ if (versionFilter !== "all") {
+ list = list.filter((v) => v.type === versionFilter);
+ }
+ if (versionSearch.trim()) {
+ const q = versionSearch.trim().toLowerCase().replace(/。/g, ".");
+ list = list.filter((v) => v.id.toLowerCase().includes(q));
+ }
+ return list;
+ }, [gameStore.versions, versionFilter, versionSearch]);
+
+ // Reset when opened/closed
+ useEffect(() => {
+ if (open) {
+ // ensure versions are loaded
+ gameStore.loadVersions();
+ setStep(1);
+ setInstanceName("");
+ setVersionSearch("");
+ setVersionFilter("release");
+ setSelectedVersionUI(null);
+ setModLoaderType("vanilla");
+ setFabricLoaders([]);
+ setForgeVersions([]);
+ setSelectedFabricLoader("");
+ setSelectedForgeLoader("");
+ setErrorMessage("");
+ setCreating(false);
+ }
+ }, [open, gameStore.loadVersions]);
+
+ function validateStep1(): boolean {
+ if (!instanceName.trim()) {
+ setErrorMessage("Please enter an instance name");
+ return false;
+ }
+ setErrorMessage("");
+ return true;
+ }
+
+ function validateStep2(): boolean {
+ if (!selectedVersionUI) {
+ setErrorMessage("Please select a Minecraft version");
+ return false;
+ }
+ setErrorMessage("");
+ return true;
+ }
+
+ async function handleNext() {
+ setErrorMessage("");
+ if (step === 1) {
+ if (!validateStep1()) return;
+ setStep(2);
+ } else if (step === 2) {
+ if (!validateStep2()) return;
+ setStep(3);
+ }
+ }
+
+ function handleBack() {
+ setErrorMessage("");
+ setStep((s) => Math.max(1, s - 1));
+ }
+
+ async function handleCreate() {
+ if (!validateStep1() || !validateStep2()) return;
+ setCreating(true);
+ setErrorMessage("");
+
+ try {
+ // Step 1: create instance
+ const instance = await invoke<Instance>("create_instance", {
+ name: instanceName.trim(),
+ });
+
+ // If selectedVersion provided, install it
+ if (selectedVersionUI) {
+ try {
+ await invoke("install_version", {
+ instanceId: instance.id,
+ versionId: selectedVersionUI.id,
+ });
+ } catch (err) {
+ console.error("Failed to install base version:", err);
+ // continue - instance created but version install failed
+ toast.error(
+ `Failed to install version ${selectedVersionUI.id}: ${String(err)}`,
+ );
+ }
+ }
+
+ // If mod loader selected, install it
+ if (modLoaderType === "fabric" && selectedFabricLoader) {
+ try {
+ await invoke("install_fabric", {
+ instanceId: instance.id,
+ gameVersion: selectedVersionUI?.id ?? "",
+ loaderVersion: selectedFabricLoader,
+ });
+ } catch (err) {
+ console.error("Failed to install Fabric:", err);
+ toast.error(`Failed to install Fabric: ${String(err)}`);
+ }
+ } else if (modLoaderType === "forge" && selectedForgeLoader) {
+ try {
+ await invoke("install_forge", {
+ instanceId: instance.id,
+ gameVersion: selectedVersionUI?.id ?? "",
+ installerVersion: selectedForgeLoader,
+ });
+ } catch (err) {
+ console.error("Failed to install Forge:", err);
+ toast.error(`Failed to install Forge: ${String(err)}`);
+ }
+ }
+
+ // Refresh instances list
+ await instancesStore.refresh();
+
+ toast.success("Instance created successfully");
+ onOpenChange(false);
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ setErrorMessage(String(e));
+ toast.error(`Failed to create instance: ${e}`);
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ // UI pieces
+ const StepIndicator = () => (
+ <div className="flex gap-2 w-full">
+ <div
+ className={`flex-1 h-1 rounded ${step >= 1 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ <div
+ className={`flex-1 h-1 rounded ${step >= 2 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ <div
+ className={`flex-1 h-1 rounded ${step >= 3 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ </div>
+ );
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="w-full max-w-3xl max-h-[90vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Create New Instance</DialogTitle>
+ <DialogDescription>
+ Multi-step wizard — create an instance and optionally install a
+ version or mod loader.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="px-6">
+ <div className="pt-4 pb-6">
+ <StepIndicator />
+ </div>
+
+ {/* Step 1 - Name */}
+ {step === 1 && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="instance-name"
+ className="block text-sm font-medium mb-2"
+ >
+ Instance Name
+ </label>
+ <Input
+ id="instance-name"
+ placeholder="My Minecraft Instance"
+ value={instanceName}
+ onChange={(e) => setInstanceName(e.target.value)}
+ disabled={creating}
+ />
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Give your instance a memorable name.
+ </p>
+ </div>
+ )}
+
+ {/* Step 2 - Version selection */}
+ {step === 2 && (
+ <div className="space-y-4">
+ <div className="flex gap-3">
+ <div className="relative flex-1">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
+ <Input
+ value={versionSearch}
+ onChange={(e) => setVersionSearch(e.target.value)}
+ placeholder="Search versions..."
+ className="pl-9"
+ />
+ </div>
+
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant={versionFilter === "all" ? "default" : "outline"}
+ onClick={() => setVersionFilter("all")}
+ >
+ All
+ </Button>
+ <Button
+ type="button"
+ variant={
+ versionFilter === "release" ? "default" : "outline"
+ }
+ onClick={() => setVersionFilter("release")}
+ >
+ Release
+ </Button>
+ <Button
+ type="button"
+ variant={
+ versionFilter === "snapshot" ? "default" : "outline"
+ }
+ onClick={() => setVersionFilter("snapshot")}
+ >
+ Snapshot
+ </Button>
+ </div>
+ </div>
+
+ <ScrollArea className="max-h-[36vh]">
+ <div className="space-y-2 py-2">
+ {gameStore.versions.length === 0 ? (
+ <div className="flex items-center justify-center py-8 text-muted-foreground">
+ <Loader2 className="animate-spin mr-2" />
+ Loading versions...
+ </div>
+ ) : filteredVersions.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ No matching versions found
+ </div>
+ ) : (
+ filteredVersions.map((v) => {
+ const isSelected = selectedVersionUI?.id === v.id;
+ return (
+ <button
+ key={v.id}
+ type="button"
+ onClick={() => setSelectedVersionUI(v)}
+ className={`w-full text-left p-3 rounded-lg border transition-colors ${
+ isSelected
+ ? "bg-indigo-50 dark:bg-indigo-600/20 border-indigo-200"
+ : "bg-white/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:bg-white/60"
+ }`}
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-mono font-bold">{v.id}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {v.type}{" "}
+ {v.releaseTime
+ ? ` • ${new Date(v.releaseTime).toLocaleDateString()}`
+ : ""}
+ </div>
+ </div>
+ {v.javaVersion && (
+ <div className="text-sm">
+ Java {v.javaVersion}
+ </div>
+ )}
+ </div>
+ </button>
+ );
+ })
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ )}
+
+ {/* Step 3 - Mod loader */}
+ {step === 3 && (
+ <div className="space-y-4">
+ <div>
+ <div className="text-sm font-medium mb-2">Mod Loader Type</div>
+ <div className="flex gap-3">
+ <Button
+ type="button"
+ variant={
+ modLoaderType === "vanilla" ? "default" : "outline"
+ }
+ onClick={() => setModLoaderType("vanilla")}
+ >
+ Vanilla
+ </Button>
+ <Button
+ type="button"
+ variant={modLoaderType === "fabric" ? "default" : "outline"}
+ onClick={() => setModLoaderType("fabric")}
+ >
+ Fabric
+ </Button>
+ <Button
+ type="button"
+ variant={modLoaderType === "forge" ? "default" : "outline"}
+ onClick={() => setModLoaderType("forge")}
+ >
+ Forge
+ </Button>
+ </div>
+ </div>
+
+ {modLoaderType === "fabric" && (
+ <div>
+ {loadingLoaders ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="animate-spin" />
+ Loading Fabric versions...
+ </div>
+ ) : fabricLoaders.length > 0 ? (
+ <div className="space-y-2">
+ <select
+ value={selectedFabricLoader}
+ onChange={(e) =>
+ setSelectedFabricLoader(e.target.value)
+ }
+ className="w-full px-3 py-2 rounded border bg-transparent"
+ >
+ {fabricLoaders.map((f) => (
+ <option
+ key={f.loader.version}
+ value={f.loader.version}
+ >
+ {f.loader.version}{" "}
+ {f.loader.stable ? "(Stable)" : "(Beta)"}
+ </option>
+ ))}
+ </select>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ No Fabric loaders available for this version
+ </p>
+ )}
+ </div>
+ )}
+
+ {modLoaderType === "forge" && (
+ <div>
+ {loadingLoaders ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="animate-spin" />
+ Loading Forge versions...
+ </div>
+ ) : forgeVersions.length > 0 ? (
+ <div className="space-y-2">
+ <select
+ value={selectedForgeLoader}
+ onChange={(e) => setSelectedForgeLoader(e.target.value)}
+ className="w-full px-3 py-2 rounded border bg-transparent"
+ >
+ {forgeVersions.map((f) => (
+ // binding ForgeVersion uses `version` as the identifier
+ <option key={f.version} value={f.version}>
+ {f.version}
+ </option>
+ ))}
+ </select>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ No Forge versions available for this version
+ </p>
+ )}
+ </div>
+ )}
+ </div>
+ )}
+
+ {errorMessage && (
+ <div className="text-sm text-red-400 mt-3">{errorMessage}</div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <div className="w-full flex justify-between items-center">
+ <div>
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={() => {
+ // cancel
+ onOpenChange(false);
+ }}
+ disabled={creating}
+ >
+ Cancel
+ </Button>
+ </div>
+
+ <div className="flex gap-2">
+ {step > 1 && (
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleBack}
+ disabled={creating}
+ >
+ Back
+ </Button>
+ )}
+
+ {step < 3 ? (
+ <Button type="button" onClick={handleNext} disabled={creating}>
+ Next
+ </Button>
+ ) : (
+ <Button
+ type="button"
+ onClick={handleCreate}
+ disabled={creating}
+ >
+ {creating ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Creating...
+ </>
+ ) : (
+ "Create"
+ )}
+ </Button>
+ )}
+ </div>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export default InstanceCreationModal;
diff --git a/packages/ui/src/components/instance-editor-modal.tsx b/packages/ui/src/components/instance-editor-modal.tsx
new file mode 100644
index 0000000..f880c20
--- /dev/null
+++ b/packages/ui/src/components/instance-editor-modal.tsx
@@ -0,0 +1,548 @@
+import { invoke } from "@tauri-apps/api/core";
+import { Folder, Loader2, Save, Trash2, X } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+
+import { toNumber } from "@/lib/tsrs-utils";
+import { useInstancesStore } from "@/models/instances";
+import { useSettingsStore } from "@/models/settings";
+import type { FileInfo } from "../types/bindings/core";
+import type { Instance } from "../types/bindings/instance";
+
+type Props = {
+ open: boolean;
+ instance: Instance | null;
+ onOpenChange: (open: boolean) => void;
+};
+
+export function InstanceEditorModal({ open, instance, onOpenChange }: Props) {
+ const instancesStore = useInstancesStore();
+ const { config } = useSettingsStore();
+
+ const [activeTab, setActiveTab] = useState<
+ "info" | "version" | "files" | "settings"
+ >("info");
+ const [saving, setSaving] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+
+ // Info tab fields
+ const [editName, setEditName] = useState("");
+ const [editNotes, setEditNotes] = useState("");
+
+ // Files tab state
+ const [selectedFileFolder, setSelectedFileFolder] = useState<
+ "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots"
+ >("mods");
+ const [fileList, setFileList] = useState<FileInfo[]>([]);
+ const [loadingFiles, setLoadingFiles] = useState(false);
+ const [deletingPath, setDeletingPath] = useState<string | null>(null);
+
+ // Version tab state (placeholder - the Svelte implementation used a ModLoaderSelector component)
+ // React versions-view/instance-creation handle mod loader installs; here we show basic current info.
+
+ // Settings tab fields
+ const [editMemoryMin, setEditMemoryMin] = useState<number>(0);
+ const [editMemoryMax, setEditMemoryMax] = useState<number>(0);
+ const [editJavaArgs, setEditJavaArgs] = useState<string>("");
+
+ // initialize when open & instance changes
+ useEffect(() => {
+ if (open && instance) {
+ setActiveTab("info");
+ setSaving(false);
+ setErrorMessage("");
+ setEditName(instance.name || "");
+ setEditNotes(instance.notes ?? "");
+ setEditMemoryMin(
+ (instance.memoryOverride && toNumber(instance.memoryOverride.min)) ??
+ config?.minMemory ??
+ 512,
+ );
+ setEditMemoryMax(
+ (instance.memoryOverride && toNumber(instance.memoryOverride.max)) ??
+ config?.maxMemory ??
+ 2048,
+ );
+ setEditJavaArgs(instance.jvmArgsOverride ?? "");
+ setFileList([]);
+ setSelectedFileFolder("mods");
+ }
+ }, [open, instance, config?.minMemory, config?.maxMemory]);
+
+ // load files when switching to files tab
+ const loadFileList = useCallback(
+ async (
+ folder:
+ | "mods"
+ | "resourcepacks"
+ | "shaderpacks"
+ | "saves"
+ | "screenshots",
+ ) => {
+ if (!instance) return;
+ setLoadingFiles(true);
+ try {
+ const files = await invoke<FileInfo[]>("list_instance_directory", {
+ instanceId: instance.id,
+ folder,
+ });
+ setFileList(files || []);
+ } catch (err) {
+ console.error("Failed to load files:", err);
+ toast.error("Failed to load files: " + String(err));
+ setFileList([]);
+ } finally {
+ setLoadingFiles(false);
+ }
+ },
+ [instance],
+ );
+
+ useEffect(() => {
+ if (open && instance && activeTab === "files") {
+ // explicitly pass the selected folder so loadFileList doesn't rely on stale closures
+ loadFileList(selectedFileFolder);
+ }
+ }, [activeTab, open, instance, selectedFileFolder, loadFileList]);
+
+ async function changeFolder(
+ folder: "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots",
+ ) {
+ setSelectedFileFolder(folder);
+ // reload the list for the newly selected folder
+ if (open && instance) await loadFileList(folder);
+ }
+
+ async function deleteFile(filePath: string) {
+ if (
+ !confirm(
+ `Are you sure you want to delete "${filePath.split("/").pop()}"?`,
+ )
+ ) {
+ return;
+ }
+ setDeletingPath(filePath);
+ try {
+ await invoke("delete_instance_file", { path: filePath });
+ // refresh the currently selected folder
+ await loadFileList(selectedFileFolder);
+ toast.success("Deleted");
+ } catch (err) {
+ console.error("Failed to delete file:", err);
+ toast.error("Failed to delete file: " + String(err));
+ } finally {
+ setDeletingPath(null);
+ }
+ }
+
+ async function openInExplorer(filePath: string) {
+ try {
+ await invoke("open_file_explorer", { path: filePath });
+ } catch (err) {
+ console.error("Failed to open in explorer:", err);
+ toast.error("Failed to open file explorer: " + String(err));
+ }
+ }
+
+ async function saveChanges() {
+ if (!instance) return;
+ if (!editName.trim()) {
+ setErrorMessage("Instance name cannot be empty");
+ return;
+ }
+ setSaving(true);
+ setErrorMessage("");
+ try {
+ // Build updated instance shape compatible with backend
+ const updatedInstance: Instance = {
+ ...instance,
+ name: editName.trim(),
+ // some bindings may use camelCase; set optional string fields to null when empty
+ notes: editNotes.trim() ? editNotes.trim() : null,
+ memoryOverride: {
+ min: editMemoryMin,
+ max: editMemoryMax,
+ },
+ jvmArgsOverride: editJavaArgs.trim() ? editJavaArgs.trim() : null,
+ };
+
+ await instancesStore.update(updatedInstance as Instance);
+ toast.success("Instance saved");
+ onOpenChange(false);
+ } catch (err) {
+ console.error("Failed to save instance:", err);
+ setErrorMessage(String(err));
+ toast.error("Failed to save instance: " + String(err));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function formatFileSize(bytesBig: FileInfo["size"]): string {
+ const bytes = Number(bytesBig ?? 0);
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`;
+ }
+
+ function formatDate(
+ tsBig?:
+ | FileInfo["modified"]
+ | Instance["createdAt"]
+ | Instance["lastPlayed"],
+ ) {
+ if (tsBig === undefined || tsBig === null) return "";
+ const n = toNumber(tsBig);
+ // tsrs bindings often use seconds for createdAt/lastPlayed; if value looks like seconds use *1000
+ const maybeMs = n > 1e12 ? n : n * 1000;
+ return new Date(maybeMs).toLocaleDateString();
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="w-full max-w-4xl max-h-[90vh] overflow-hidden">
+ <DialogHeader>
+ <div className="flex items-center justify-between gap-4">
+ <div>
+ <DialogTitle>Edit Instance</DialogTitle>
+ <DialogDescription>{instance?.name ?? ""}</DialogDescription>
+ </div>
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={() => onOpenChange(false)}
+ disabled={saving}
+ className="p-2 rounded hover:bg-zinc-800 text-zinc-400"
+ aria-label="Close"
+ >
+ <X />
+ </button>
+ </div>
+ </div>
+ </DialogHeader>
+
+ {/* Tab Navigation */}
+ <div className="flex gap-1 px-6 pt-2 border-b border-zinc-700">
+ {[
+ { id: "info", label: "Info" },
+ { id: "version", label: "Version" },
+ { id: "files", label: "Files" },
+ { id: "settings", label: "Settings" },
+ ].map((tab) => (
+ <button
+ type="button"
+ key={tab.id}
+ onClick={() =>
+ setActiveTab(
+ tab.id as "info" | "version" | "files" | "settings",
+ )
+ }
+ className={`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>
+ ))}
+ </div>
+
+ {/* Content */}
+ <div className="p-6 overflow-y-auto max-h-[60vh]">
+ {activeTab === "info" && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="instance-name-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Instance Name
+ </label>
+ <Input
+ id="instance-name-edit"
+ value={editName}
+ onChange={(e) => setEditName(e.target.value)}
+ disabled={saving}
+ />
+ </div>
+
+ <div>
+ <label
+ htmlFor="instance-notes-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Notes
+ </label>
+ <Textarea
+ id="instance-notes-edit"
+ value={editNotes}
+ onChange={(e) => setEditNotes(e.target.value)}
+ rows={4}
+ disabled={saving}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Created</p>
+ <p className="text-white font-medium">
+ {instance?.createdAt ? formatDate(instance.createdAt) : "-"}
+ </p>
+ </div>
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Last Played</p>
+ <p className="text-white font-medium">
+ {instance?.lastPlayed
+ ? formatDate(instance.lastPlayed)
+ : "Never"}
+ </p>
+ </div>
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Game Directory</p>
+ <p
+ className="text-white font-medium text-xs truncate"
+ title={instance?.gameDir ?? ""}
+ >
+ {instance?.gameDir
+ ? String(instance.gameDir).split("/").pop()
+ : ""}
+ </p>
+ </div>
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Current Version</p>
+ <p className="text-white font-medium">
+ {instance?.versionId ?? "None"}
+ </p>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {activeTab === "version" && (
+ <div className="space-y-4">
+ {instance?.versionId ? (
+ <div className="p-4 bg-indigo-500/10 border border-indigo-500/30 rounded-lg">
+ <p className="text-sm text-indigo-400">
+ Currently playing:{" "}
+ <span className="font-medium">{instance.versionId}</span>
+ {instance.modLoader && (
+ <>
+ {" "}
+ with{" "}
+ <span className="capitalize">{instance.modLoader}</span>
+ {instance.modLoaderVersion
+ ? ` ${instance.modLoaderVersion}`
+ : ""}
+ </>
+ )}
+ </p>
+ </div>
+ ) : (
+ <div className="text-sm text-zinc-400">
+ No version selected for this instance
+ </div>
+ )}
+
+ <div>
+ <p className="text-sm font-medium mb-2">
+ Change Version / Mod Loader
+ </p>
+ <p className="text-xs text-zinc-400">
+ Use the Versions page to install new game versions or mod
+ loaders, then set them here.
+ </p>
+ </div>
+ </div>
+ )}
+
+ {activeTab === "files" && (
+ <div className="space-y-4">
+ <div className="flex gap-2 flex-wrap">
+ {(
+ [
+ "mods",
+ "resourcepacks",
+ "shaderpacks",
+ "saves",
+ "screenshots",
+ ] as const
+ ).map((folder) => (
+ <button
+ type="button"
+ key={folder}
+ onClick={() => changeFolder(folder)}
+ className={`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>
+ ))}
+ </div>
+
+ {loadingFiles ? (
+ <div className="flex items-center gap-2 text-zinc-400 py-8 justify-center">
+ <Loader2 className="animate-spin" />
+ Loading files...
+ </div>
+ ) : fileList.length === 0 ? (
+ <div className="text-center py-8 text-zinc-500">
+ No files in this folder
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {fileList.map((file) => (
+ <div
+ key={file.path}
+ className="flex items-center justify-between p-3 bg-zinc-800 rounded-lg hover:bg-zinc-700 transition-colors"
+ >
+ <div className="flex-1 min-w-0">
+ <p className="font-medium text-white truncate">
+ {file.name}
+ </p>
+ <p className="text-xs text-zinc-400">
+ {file.isDirectory
+ ? "Folder"
+ : formatFileSize(file.size)}{" "}
+ • {formatDate(file.modified)}
+ </p>
+ </div>
+ <div className="flex gap-2 ml-4">
+ <button
+ type="button"
+ onClick={() => openInExplorer(file.path)}
+ title="Open in explorer"
+ className="p-2 rounded-lg hover:bg-zinc-600 text-zinc-400 hover:text-white transition-colors"
+ >
+ <Folder />
+ </button>
+ <button
+ type="button"
+ onClick={() => deleteFile(file.path)}
+ disabled={deletingPath === file.path}
+ title="Delete"
+ className="p-2 rounded-lg hover:bg-red-600/20 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
+ >
+ {deletingPath === file.path ? (
+ <Loader2 className="animate-spin" />
+ ) : (
+ <Trash2 />
+ )}
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+
+ {activeTab === "settings" && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="min-memory-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Minimum Memory (MB)
+ </label>
+ <Input
+ id="min-memory-edit"
+ type="number"
+ value={String(editMemoryMin)}
+ onChange={(e) => setEditMemoryMin(Number(e.target.value))}
+ disabled={saving}
+ />
+ <p className="text-xs text-zinc-400 mt-1">
+ Default: {config?.minMemory} MB
+ </p>
+ </div>
+
+ <div>
+ <label
+ htmlFor="max-memory-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Maximum Memory (MB)
+ </label>
+ <Input
+ id="max-memory-edit"
+ type="number"
+ value={String(editMemoryMax)}
+ onChange={(e) => setEditMemoryMax(Number(e.target.value))}
+ disabled={saving}
+ />
+ <p className="text-xs text-zinc-400 mt-1">
+ Default: {config?.maxMemory} MB
+ </p>
+ </div>
+
+ <div>
+ <label
+ htmlFor="jvm-args-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ JVM Arguments (Advanced)
+ </label>
+ <Textarea
+ id="jvm-args-edit"
+ value={editJavaArgs}
+ onChange={(e) => setEditJavaArgs(e.target.value)}
+ rows={4}
+ disabled={saving}
+ />
+ </div>
+ </div>
+ )}
+ </div>
+
+ {errorMessage && (
+ <div className="px-6 text-sm text-red-400">{errorMessage}</div>
+ )}
+
+ <DialogFooter>
+ <div className="flex items-center justify-between w-full">
+ <div />
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button onClick={saveChanges} disabled={saving}>
+ {saving ? (
+ <Loader2 className="animate-spin mr-2" />
+ ) : (
+ <Save className="mr-2" />
+ )}
+ Save
+ </Button>
+ </div>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export default InstanceEditorModal;
diff --git a/packages/ui/src/components/login-modal.tsx b/packages/ui/src/components/login-modal.tsx
new file mode 100644
index 0000000..49596da
--- /dev/null
+++ b/packages/ui/src/components/login-modal.tsx
@@ -0,0 +1,188 @@
+import { Mail, User } from "lucide-react";
+import { useCallback, useState } from "react";
+import { toast } from "sonner";
+import { useAuthStore } from "@/models/auth";
+import { Button } from "./ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "./ui/dialog";
+import {
+ Field,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+} from "./ui/field";
+import { Input } from "./ui/input";
+
+export interface LoginModalProps
+ extends Omit<React.ComponentPropsWithoutRef<typeof Dialog>, "onOpenChange"> {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function LoginModal({ onOpenChange, ...props }: LoginModalProps) {
+ const authStore = useAuthStore();
+
+ const [offlineUsername, setOfflineUsername] = useState<string>("");
+ const [errorMessage, setErrorMessage] = useState<string>("");
+ const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
+
+ const handleMicrosoftLogin = useCallback(async () => {
+ setIsLoggingIn(true);
+ authStore.setLoginMode("microsoft");
+ try {
+ await authStore.loginOnline(() => onOpenChange?.(false));
+ } catch (error) {
+ const err = error as Error;
+ console.error("Failed to login with Microsoft:", err);
+ setErrorMessage(err.message);
+ } finally {
+ setIsLoggingIn(false);
+ }
+ }, [authStore.loginOnline, authStore.setLoginMode, onOpenChange]);
+
+ const handleOfflineLogin = useCallback(async () => {
+ setIsLoggingIn(true);
+ try {
+ await authStore.loginOffline(offlineUsername);
+ toast.success("Logged in offline successfully");
+ onOpenChange?.(false);
+ } catch (error) {
+ const err = error as Error;
+ console.error("Failed to login offline:", err);
+ setErrorMessage(err.message);
+ } finally {
+ setIsLoggingIn(false);
+ }
+ }, [authStore, offlineUsername, onOpenChange]);
+
+ return (
+ <Dialog onOpenChange={onOpenChange} {...props}>
+ <DialogContent className="md:max-w-md">
+ <DialogHeader>
+ <DialogTitle>Login</DialogTitle>
+ <DialogDescription>
+ Login to your Minecraft account or play offline
+ </DialogDescription>
+ </DialogHeader>
+ <div className="p-4 w-full overflow-hidden">
+ {!authStore.loginMode && (
+ <div className="flex flex-col space-y-4">
+ <Button size="lg" onClick={handleMicrosoftLogin}>
+ <Mail />
+ Login with Microsoft
+ </Button>
+ <Button
+ variant="secondary"
+ onClick={() => authStore.setLoginMode("offline")}
+ size="lg"
+ >
+ <User />
+ Login Offline
+ </Button>
+ </div>
+ )}
+ {authStore.loginMode === "microsoft" && (
+ <div className="flex flex-col space-y-4">
+ <button
+ type="button"
+ className="text-4xl font-bold text-center bg-accent p-4 cursor-pointer"
+ onClick={() => {
+ if (authStore.deviceCode?.userCode) {
+ navigator.clipboard?.writeText(
+ authStore.deviceCode?.userCode,
+ );
+ toast.success("Copied to clipboard");
+ }
+ }}
+ >
+ {authStore.deviceCode?.userCode}
+ </button>
+ <span className="text-muted-foreground w-full overflow-hidden text-ellipsis">
+ To sign in, use a web browser to open the page{" "}
+ <a href={authStore.deviceCode?.verificationUri}>
+ {authStore.deviceCode?.verificationUri}
+ </a>{" "}
+ and enter the code{" "}
+ <code
+ className="font-semibold cursor-pointer"
+ onClick={() => {
+ if (authStore.deviceCode?.userCode) {
+ navigator.clipboard?.writeText(
+ authStore.deviceCode?.userCode,
+ );
+ }
+ }}
+ onKeyDown={() => {
+ if (authStore.deviceCode?.userCode) {
+ navigator.clipboard?.writeText(
+ authStore.deviceCode?.userCode,
+ );
+ }
+ }}
+ >
+ {authStore.deviceCode?.userCode}
+ </code>{" "}
+ to authenticate, this code will be expired in{" "}
+ {authStore.deviceCode?.expiresIn} seconds.
+ </span>
+ <FieldError>{errorMessage}</FieldError>
+ </div>
+ )}
+ {authStore.loginMode === "offline" && (
+ <FieldGroup>
+ <Field>
+ <FieldLabel>Username</FieldLabel>
+ <FieldDescription>
+ Enter a username to play offline
+ </FieldDescription>
+ <Input
+ value={offlineUsername}
+ onChange={(e) => {
+ setOfflineUsername(e.target.value);
+ setErrorMessage("");
+ }}
+ aria-invalid={!!errorMessage}
+ />
+ <FieldError>{errorMessage}</FieldError>
+ </Field>
+ </FieldGroup>
+ )}
+ </div>
+ <DialogFooter>
+ <div className="flex flex-col justify-center items-center">
+ <span className="text-xs text-muted-foreground ">
+ {authStore.statusMessage}
+ </span>
+ </div>
+ <Button
+ variant="outline"
+ onClick={() => {
+ if (authStore.loginMode) {
+ if (authStore.loginMode === "microsoft") {
+ authStore.cancelLoginOnline();
+ }
+ authStore.setLoginMode(null);
+ } else {
+ onOpenChange?.(false);
+ }
+ }}
+ >
+ Cancel
+ </Button>
+ {authStore.loginMode === "offline" && (
+ <Button onClick={handleOfflineLogin} disabled={isLoggingIn}>
+ Login
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/packages/ui/src/components/particle-background.tsx b/packages/ui/src/components/particle-background.tsx
new file mode 100644
index 0000000..2e0b15a
--- /dev/null
+++ b/packages/ui/src/components/particle-background.tsx
@@ -0,0 +1,63 @@
+import { useEffect, useRef } from "react";
+import { SaturnEffect } from "../lib/effects/SaturnEffect";
+
+export function ParticleBackground() {
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
+ const effectRef = useRef<SaturnEffect | null>(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ // Instantiate SaturnEffect and attach to canvas
+ let effect: SaturnEffect | null = null;
+ try {
+ effect = new SaturnEffect(canvas);
+ effectRef.current = effect;
+ } catch (err) {
+ // If effect fails, silently degrade (keep background blank)
+ // eslint-disable-next-line no-console
+ console.warn("SaturnEffect initialization failed:", err);
+ }
+
+ const resizeHandler = () => {
+ if (effectRef.current) {
+ try {
+ effectRef.current.resize(window.innerWidth, window.innerHeight);
+ } catch {
+ // ignore
+ }
+ }
+ };
+
+ window.addEventListener("resize", resizeHandler);
+
+ // Expose getter for HomeView interactions (getSaturnEffect)
+ // HomeView will call window.getSaturnEffect()?.handleMouseDown/Move/Up
+ (
+ window as unknown as { getSaturnEffect?: () => SaturnEffect | null }
+ ).getSaturnEffect = () => effectRef.current;
+
+ return () => {
+ window.removeEventListener("resize", resizeHandler);
+ if (effectRef.current) {
+ try {
+ effectRef.current.destroy();
+ } catch {
+ // ignore
+ }
+ }
+ effectRef.current = null;
+ (
+ window as unknown as { getSaturnEffect?: () => SaturnEffect | null }
+ ).getSaturnEffect = undefined;
+ };
+ }, []);
+
+ return (
+ <canvas
+ ref={canvasRef}
+ className="absolute inset-0 z-0 pointer-events-none"
+ />
+ );
+}
diff --git a/packages/ui/src/components/sidebar.tsx b/packages/ui/src/components/sidebar.tsx
new file mode 100644
index 0000000..0147b0a
--- /dev/null
+++ b/packages/ui/src/components/sidebar.tsx
@@ -0,0 +1,185 @@
+import { Folder, Home, LogOutIcon, Settings } from "lucide-react";
+import { useLocation, useNavigate } from "react-router";
+import { cn } from "@/lib/utils";
+import { useAuthStore } from "@/models/auth";
+import { Button } from "./ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
+import { UserAvatar } from "./user-avatar";
+
+interface NavItemProps {
+ Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
+ label: string;
+ to: string;
+}
+
+function NavItem({ Icon, label, to }: NavItemProps) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const isActive = location.pathname === to;
+
+ const handleClick = () => {
+ navigate(to);
+ };
+
+ return (
+ <Button
+ variant="ghost"
+ className={cn(
+ "w-fit lg:w-full justify-center lg:justify-start",
+ isActive && "relative bg-accent",
+ )}
+ size="lg"
+ onClick={handleClick}
+ >
+ <Icon className="size-5" strokeWidth={isActive ? 2.5 : 2} />
+ <span className="hidden lg:block text-sm relative z-10">{label}</span>
+ {isActive && (
+ <div className="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>
+ )}
+ </Button>
+ );
+}
+
+export function Sidebar() {
+ const authStore = useAuthStore();
+
+ return (
+ <aside
+ className={cn(
+ "flex flex-col items-center lg:items-start",
+ "bg-sidebar transition-all duration-300",
+ "w-20 lg:w-64 shrink-0 py-6 h-full",
+ )}
+ >
+ {/* Logo Area */}
+ <div className="h-16 w-full flex items-center justify-center lg:justify-start lg:px-6 mb-6">
+ {/* Icon Logo (Small) */}
+ <div className="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"
+ >
+ <title>Logo</title>
+ <path
+ d="M25 25 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M25 75 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M50 50 L75 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="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 className="hidden lg:flex items-center gap-3 font-bold text-xl tracking-tighter dark:text-white text-black">
+ <svg
+ width="42"
+ height="42"
+ viewBox="0 0 100 100"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ className="shrink-0"
+ >
+ <title>Logo</title>
+ <path
+ d="M25 25 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M25 75 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M50 50 L75 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="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="25"
+ r="7"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeDasharray="4 2"
+ fill="none"
+ className="opacity-30"
+ />
+ <circle
+ cx="50"
+ cy="75"
+ r="7"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeDasharray="4 2"
+ fill="none"
+ className="opacity-30"
+ />
+ <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
+ <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
+ </svg>
+
+ <span>DROPOUT</span>
+ </div>
+ </div>
+
+ <nav className="w-full flex flex-col space-y-1 px-3 items-center">
+ <NavItem Icon={Home} label="Overview" to="/" />
+ <NavItem Icon={Folder} label="Instances" to="/instances" />
+ <NavItem Icon={Settings} label="Settings" to="/settings" />
+ </nav>
+
+ <div className="flex-1 flex flex-col justify-end">
+ <DropdownMenu>
+ <DropdownMenuTrigger render={<UserAvatar />}>
+ Open
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" side="right" sideOffset={20}>
+ <DropdownMenuGroup>
+ <DropdownMenuItem
+ variant="destructive"
+ onClick={authStore.logout}
+ >
+ <LogOutIcon />
+ Logout
+ </DropdownMenuItem>
+ </DropdownMenuGroup>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </aside>
+ );
+}
diff --git a/packages/ui/src/components/ui/avatar.tsx b/packages/ui/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..9fd72a2
--- /dev/null
+++ b/packages/ui/src/components/ui/avatar.tsx
@@ -0,0 +1,107 @@
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: AvatarPrimitive.Root.Props & {
+ size?: "default" | "sm" | "lg";
+}) {
+ return (
+ <AvatarPrimitive.Root
+ data-slot="avatar"
+ data-size={size}
+ className={cn(
+ "size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+ <AvatarPrimitive.Image
+ data-slot="avatar-image"
+ className={cn(
+ "rounded-full aspect-square size-full object-cover",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: AvatarPrimitive.Fallback.Props) {
+ return (
+ <AvatarPrimitive.Fallback
+ data-slot="avatar-fallback"
+ className={cn(
+ "bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="avatar-badge"
+ className={cn(
+ "bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
+ "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="avatar-group"
+ className={cn(
+ "*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="avatar-group-count"
+ className={cn(
+ "bg-muted text-muted-foreground size-8 rounded-full text-xs group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+};
diff --git a/packages/ui/src/components/ui/badge.tsx b/packages/ui/src/components/ui/badge.tsx
new file mode 100644
index 0000000..425ab9e
--- /dev/null
+++ b/packages/ui/src/components/ui/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "h-5 gap-1 rounded-none border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ });
+}
+
+export { Badge, badgeVariants };
diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx
new file mode 100644
index 0000000..7dee494
--- /dev/null
+++ b/packages/ui/src/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import { Button as ButtonPrimitive } from "@base-ui/react/button";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
+ destructive:
+ "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
+ icon: "size-8",
+ "icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-7 rounded-none",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
+ return (
+ <ButtonPrimitive
+ data-slot="button"
+ className={cn(buttonVariants({ variant, size, className }))}
+ {...props}
+ />
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/packages/ui/src/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx
new file mode 100644
index 0000000..b7084a0
--- /dev/null
+++ b/packages/ui/src/components/ui/card.tsx
@@ -0,0 +1,103 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+ <div
+ data-slot="card"
+ data-size={size}
+ className={cn(
+ "ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none group/card flex flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-header"
+ className={cn(
+ "gap-1 rounded-none px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-title"
+ className={cn(
+ "text-sm font-medium group-data-[size=sm]/card:text-sm",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-description"
+ className={cn("text-muted-foreground text-xs/relaxed", className)}
+ {...props}
+ />
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-action"
+ className={cn(
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-content"
+ className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
+ {...props}
+ />
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-footer"
+ className={cn(
+ "rounded-none border-t p-4 group-data-[size=sm]/card:p-3 flex items-center",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+};
diff --git a/packages/ui/src/components/ui/checkbox.tsx b/packages/ui/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..9f22cea
--- /dev/null
+++ b/packages/ui/src/components/ui/checkbox.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
+import { CheckIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
+ return (
+ <CheckboxPrimitive.Root
+ data-slot="checkbox"
+ className={cn(
+ "border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-none border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-1 aria-invalid:ring-1 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <CheckboxPrimitive.Indicator
+ data-slot="checkbox-indicator"
+ className="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
+ >
+ <CheckIcon />
+ </CheckboxPrimitive.Indicator>
+ </CheckboxPrimitive.Root>
+ );
+}
+
+export { Checkbox };
diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..033b47c
--- /dev/null
+++ b/packages/ui/src/components/ui/dialog.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
+import { XIcon } from "lucide-react";
+import type * as React from "react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />;
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+ <DialogPrimitive.Backdrop
+ data-slot="dialog-overlay"
+ className={cn(
+ "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean;
+}) {
+ return (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Popup
+ data-slot="dialog-content"
+ className={cn(
+ "bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-none p-4 text-xs/relaxed ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ {showCloseButton && (
+ <DialogPrimitive.Close
+ data-slot="dialog-close"
+ render={
+ <Button
+ variant="ghost"
+ className="absolute top-2 right-2"
+ size="icon-sm"
+ />
+ }
+ >
+ <XIcon />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ )}
+ </DialogPrimitive.Popup>
+ </DialogPortal>
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="dialog-header"
+ className={cn("gap-1 text-left flex flex-col", className)}
+ {...props}
+ />
+ );
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean;
+}) {
+ return (
+ <div
+ data-slot="dialog-footer"
+ className={cn(
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ {showCloseButton && (
+ <DialogPrimitive.Close render={<Button variant="outline" />}>
+ Close
+ </DialogPrimitive.Close>
+ )}
+ </div>
+ );
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+ <DialogPrimitive.Title
+ data-slot="dialog-title"
+ className={cn("text-sm font-medium", className)}
+ {...props}
+ />
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+ <DialogPrimitive.Description
+ data-slot="dialog-description"
+ className={cn(
+ "text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed *:[a]:underline *:[a]:underline-offset-3",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..ee97374
--- /dev/null
+++ b/packages/ui/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,269 @@
+import { Menu as MenuPrimitive } from "@base-ui/react/menu";
+import { CheckIcon, ChevronRightIcon } from "lucide-react";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+ <MenuPrimitive.Portal>
+ <MenuPrimitive.Positioner
+ className="isolate z-50 outline-none"
+ align={align}
+ alignOffset={alignOffset}
+ side={side}
+ sideOffset={sideOffset}
+ >
+ <MenuPrimitive.Popup
+ data-slot="dropdown-menu-content"
+ className={cn(
+ "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-none shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden",
+ className,
+ )}
+ {...props}
+ />
+ </MenuPrimitive.Positioner>
+ </MenuPrimitive.Portal>
+ );
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.GroupLabel
+ data-slot="dropdown-menu-label"
+ data-inset={inset}
+ className={cn(
+ "text-muted-foreground px-2 py-2 text-xs data-inset:pl-7",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+ <MenuPrimitive.Item
+ data-slot="dropdown-menu-item"
+ data-inset={inset}
+ data-variant={variant}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none px-2 py-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.SubmenuTrigger
+ data-slot="dropdown-menu-sub-trigger"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none px-2 py-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 data-popup-open:bg-accent data-popup-open:text-accent-foreground flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronRightIcon className="ml-auto" />
+ </MenuPrimitive.SubmenuTrigger>
+ );
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuContent>) {
+ return (
+ <DropdownMenuContent
+ data-slot="dropdown-menu-sub-content"
+ className={cn(
+ "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-24 rounded-none shadow-lg ring-1 duration-100 w-auto",
+ className,
+ )}
+ align={align}
+ alignOffset={alignOffset}
+ side={side}
+ sideOffset={sideOffset}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.CheckboxItem
+ data-slot="dropdown-menu-checkbox-item"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ checked={checked}
+ {...props}
+ >
+ <span
+ className="absolute right-2 flex items-center justify-center pointer-events-none"
+ data-slot="dropdown-menu-checkbox-item-indicator"
+ >
+ <MenuPrimitive.CheckboxItemIndicator>
+ <CheckIcon />
+ </MenuPrimitive.CheckboxItemIndicator>
+ </span>
+ {children}
+ </MenuPrimitive.CheckboxItem>
+ );
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+ <MenuPrimitive.RadioGroup
+ data-slot="dropdown-menu-radio-group"
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.RadioItem
+ data-slot="dropdown-menu-radio-item"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ <span
+ className="absolute right-2 flex items-center justify-center pointer-events-none"
+ data-slot="dropdown-menu-radio-item-indicator"
+ >
+ <MenuPrimitive.RadioItemIndicator>
+ <CheckIcon />
+ </MenuPrimitive.RadioItemIndicator>
+ </span>
+ {children}
+ </MenuPrimitive.RadioItem>
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+ <MenuPrimitive.Separator
+ data-slot="dropdown-menu-separator"
+ className={cn("bg-border -mx-1 h-px", className)}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="dropdown-menu-shortcut"
+ className={cn(
+ "text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/packages/ui/src/components/ui/field.tsx b/packages/ui/src/components/ui/field.tsx
new file mode 100644
index 0000000..ab9fb71
--- /dev/null
+++ b/packages/ui/src/components/ui/field.tsx
@@ -0,0 +1,238 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { useMemo } from "react";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+ <fieldset
+ data-slot="field-set"
+ className={cn(
+ "gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldLegend({
+ className,
+ variant = "legend",
+ ...props
+}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
+ return (
+ <legend
+ data-slot="field-legend"
+ data-variant={variant}
+ className={cn(
+ "mb-2.5 font-medium data-[variant=label]:text-xs data-[variant=legend]:text-sm",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-group"
+ className={cn(
+ "gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const fieldVariants = cva(
+ "data-[invalid=true]:text-destructive gap-2 group/field flex w-full",
+ {
+ variants: {
+ orientation: {
+ vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
+ horizontal:
+ "flex-row items-center *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ responsive:
+ "flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:*:data-[slot=field-label]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ },
+ },
+ defaultVariants: {
+ orientation: "vertical",
+ },
+ },
+);
+
+function Field({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
+ return (
+ <div
+ data-slot="field"
+ data-orientation={orientation}
+ className={cn(fieldVariants({ orientation }), className)}
+ {...props}
+ />
+ );
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-content"
+ className={cn(
+ "gap-0.5 group/field-content flex flex-1 flex-col leading-snug",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps<typeof Label>) {
+ return (
+ <Label
+ data-slot="field-label"
+ className={cn(
+ "has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 group/field-label peer/field-label flex w-fit leading-snug",
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-label"
+ className={cn(
+ "gap-2 text-xs/relaxed group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ <p
+ data-slot="field-description"
+ className={cn(
+ "text-muted-foreground text-left text-xs/relaxed [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance",
+ "last:mt-0 nth-last-2:-mt-1",
+ "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode;
+}) {
+ return (
+ <div
+ data-slot="field-separator"
+ data-content={!!children}
+ className={cn(
+ "-my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2 relative",
+ className,
+ )}
+ {...props}
+ >
+ <Separator className="absolute inset-0 top-1/2" />
+ {children && (
+ <span
+ className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
+ data-slot="field-separator-content"
+ >
+ {children}
+ </span>
+ )}
+ </div>
+ );
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined>;
+}) {
+ const content = useMemo(() => {
+ if (children) {
+ return children;
+ }
+
+ if (!errors?.length) {
+ return null;
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ];
+
+ if (uniqueErrors?.length === 1) {
+ return uniqueErrors[0]?.message;
+ }
+
+ return (
+ <ul className="ml-4 flex list-disc flex-col gap-1">
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && (
+ <li key={`${error.message.slice(6)}-${index}`}>
+ {error.message}
+ </li>
+ ),
+ )}
+ </ul>
+ );
+ }, [children, errors]);
+
+ if (!content) {
+ return null;
+ }
+
+ return (
+ <div
+ role="alert"
+ data-slot="field-error"
+ className={cn("text-destructive text-xs font-normal", className)}
+ {...props}
+ >
+ {content}
+ </div>
+ );
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+};
diff --git a/packages/ui/src/components/ui/input.tsx b/packages/ui/src/components/ui/input.tsx
new file mode 100644
index 0000000..bb0390a
--- /dev/null
+++ b/packages/ui/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import { Input as InputPrimitive } from "@base-ui/react/input";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+ <InputPrimitive
+ type={type}
+ data-slot="input"
+ className={cn(
+ "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-none border bg-transparent px-2.5 py-1 text-xs transition-colors file:h-6 file:text-xs file:font-medium focus-visible:ring-1 aria-invalid:ring-1 md:text-xs file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Input };
diff --git a/packages/ui/src/components/ui/label.tsx b/packages/ui/src/components/ui/label.tsx
new file mode 100644
index 0000000..9a998c7
--- /dev/null
+++ b/packages/ui/src/components/ui/label.tsx
@@ -0,0 +1,19 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+ // biome-ignore lint/a11y/noLabelWithoutControl: shadcn component
+ <label
+ data-slot="label"
+ className={cn(
+ "gap-2 text-xs leading-none group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Label };
diff --git a/packages/ui/src/components/ui/scroll-area.tsx b/packages/ui/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..4a68eb2
--- /dev/null
+++ b/packages/ui/src/components/ui/scroll-area.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
+import { cn } from "@/lib/utils";
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: ScrollAreaPrimitive.Root.Props) {
+ return (
+ <ScrollAreaPrimitive.Root
+ data-slot="scroll-area"
+ className={cn("relative", className)}
+ {...props}
+ >
+ <ScrollAreaPrimitive.Viewport
+ data-slot="scroll-area-viewport"
+ className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
+ >
+ {children}
+ </ScrollAreaPrimitive.Viewport>
+ <ScrollBar />
+ <ScrollAreaPrimitive.Corner />
+ </ScrollAreaPrimitive.Root>
+ );
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props) {
+ return (
+ <ScrollAreaPrimitive.Scrollbar
+ data-slot="scroll-area-scrollbar"
+ data-orientation={orientation}
+ orientation={orientation}
+ className={cn(
+ "data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
+ className,
+ )}
+ {...props}
+ >
+ <ScrollAreaPrimitive.Thumb
+ data-slot="scroll-area-thumb"
+ className="rounded-none bg-border relative flex-1"
+ />
+ </ScrollAreaPrimitive.Scrollbar>
+ );
+}
+
+export { ScrollArea, ScrollBar };
diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx
new file mode 100644
index 0000000..210adba
--- /dev/null
+++ b/packages/ui/src/components/ui/select.tsx
@@ -0,0 +1,199 @@
+import { Select as SelectPrimitive } from "@base-ui/react/select";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+ <SelectPrimitive.Group
+ data-slot="select-group"
+ className={cn("scroll-my-1", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+ <SelectPrimitive.Value
+ data-slot="select-value"
+ className={cn("flex flex-1 text-left", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default";
+}) {
+ return (
+ <SelectPrimitive.Trigger
+ data-slot="select-trigger"
+ data-size={size}
+ className={cn(
+ "border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-none border bg-transparent py-2 pr-2 pl-2.5 text-xs transition-colors select-none focus-visible:ring-1 aria-invalid:ring-1 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <SelectPrimitive.Icon
+ render={
+ <ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
+ }
+ />
+ </SelectPrimitive.Trigger>
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+ <SelectPrimitive.Portal>
+ <SelectPrimitive.Positioner
+ side={side}
+ sideOffset={sideOffset}
+ align={align}
+ alignOffset={alignOffset}
+ alignItemWithTrigger={alignItemWithTrigger}
+ className="isolate z-50"
+ >
+ <SelectPrimitive.Popup
+ data-slot="select-content"
+ data-align-trigger={alignItemWithTrigger}
+ className={cn(
+ "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-none shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none",
+ className,
+ )}
+ {...props}
+ >
+ <SelectScrollUpButton />
+ <SelectPrimitive.List>{children}</SelectPrimitive.List>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Popup>
+ </SelectPrimitive.Positioner>
+ </SelectPrimitive.Portal>
+ );
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+ <SelectPrimitive.GroupLabel
+ data-slot="select-label"
+ className={cn("text-muted-foreground px-2 py-2 text-xs", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+ <SelectPrimitive.Item
+ data-slot="select-item"
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ <SelectPrimitive.ItemText className="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
+ {children}
+ </SelectPrimitive.ItemText>
+ <SelectPrimitive.ItemIndicator
+ render={
+ <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
+ }
+ >
+ <CheckIcon className="pointer-events-none" />
+ </SelectPrimitive.ItemIndicator>
+ </SelectPrimitive.Item>
+ );
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+ <SelectPrimitive.Separator
+ data-slot="select-separator"
+ className={cn("bg-border -mx-1 h-px pointer-events-none", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
+ return (
+ <SelectPrimitive.ScrollUpArrow
+ data-slot="select-scroll-up-button"
+ className={cn(
+ "bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full",
+ className,
+ )}
+ {...props}
+ >
+ <ChevronUpIcon />
+ </SelectPrimitive.ScrollUpArrow>
+ );
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
+ return (
+ <SelectPrimitive.ScrollDownArrow
+ data-slot="select-scroll-down-button"
+ className={cn(
+ "bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full",
+ className,
+ )}
+ {...props}
+ >
+ <ChevronDownIcon />
+ </SelectPrimitive.ScrollDownArrow>
+ );
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/packages/ui/src/components/ui/separator.tsx b/packages/ui/src/components/ui/separator.tsx
new file mode 100644
index 0000000..e91a862
--- /dev/null
+++ b/packages/ui/src/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
+
+import { cn } from "@/lib/utils";
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+ <SeparatorPrimitive
+ data-slot="separator"
+ orientation={orientation}
+ className={cn(
+ "bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Separator };
diff --git a/packages/ui/src/components/ui/sonner.tsx b/packages/ui/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..d6e293d
--- /dev/null
+++ b/packages/ui/src/components/ui/sonner.tsx
@@ -0,0 +1,43 @@
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+} from "lucide-react";
+import { useTheme } from "next-themes";
+import { Toaster as Sonner, type ToasterProps } from "sonner";
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme();
+
+ return (
+ <Sonner
+ theme={theme as ToasterProps["theme"]}
+ className="toaster group"
+ icons={{
+ success: <CircleCheckIcon className="size-4" />,
+ info: <InfoIcon className="size-4" />,
+ warning: <TriangleAlertIcon className="size-4" />,
+ error: <OctagonXIcon className="size-4" />,
+ loading: <Loader2Icon className="size-4 animate-spin" />,
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ );
+};
+
+export { Toaster };
diff --git a/packages/ui/src/components/ui/spinner.tsx b/packages/ui/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..91f6a63
--- /dev/null
+++ b/packages/ui/src/components/ui/spinner.tsx
@@ -0,0 +1,10 @@
+import { cn } from "@/lib/utils"
+import { Loader2Icon } from "lucide-react"
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return (
+ <Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
+ )
+}
+
+export { Spinner }
diff --git a/packages/ui/src/components/ui/switch.tsx b/packages/ui/src/components/ui/switch.tsx
new file mode 100644
index 0000000..fef14e3
--- /dev/null
+++ b/packages/ui/src/components/ui/switch.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
+
+import { cn } from "@/lib/utils";
+
+function Switch({
+ className,
+ size = "default",
+ ...props
+}: SwitchPrimitive.Root.Props & {
+ size?: "sm" | "default";
+}) {
+ return (
+ <SwitchPrimitive.Root
+ data-slot="switch"
+ data-size={size}
+ className={cn(
+ "data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-1 aria-invalid:ring-1 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <SwitchPrimitive.Thumb
+ data-slot="switch-thumb"
+ className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
+ />
+ </SwitchPrimitive.Root>
+ );
+}
+
+export { Switch };
diff --git a/packages/ui/src/components/ui/tabs.tsx b/packages/ui/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..c66893f
--- /dev/null
+++ b/packages/ui/src/components/ui/tabs.tsx
@@ -0,0 +1,80 @@
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+ <TabsPrimitive.Root
+ data-slot="tabs"
+ data-orientation={orientation}
+ className={cn(
+ "gap-2 group/tabs flex data-horizontal:flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const tabsListVariants = cva(
+ "rounded-none p-0.75 group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
+ return (
+ <TabsPrimitive.List
+ data-slot="tabs-list"
+ data-variant={variant}
+ className={cn(tabsListVariants({ variant }), className)}
+ {...props}
+ />
+ );
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+ <TabsPrimitive.Tab
+ data-slot="tabs-trigger"
+ className={cn(
+ "gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium group-data-vertical/tabs:py-[calc(--spacing(1.25))] [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
+ "data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
+ "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:-bottom-1.25 group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+ <TabsPrimitive.Panel
+ data-slot="tabs-content"
+ className={cn("text-xs/relaxed flex-1 outline-none", className)}
+ {...props}
+ />
+ );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
diff --git a/packages/ui/src/components/ui/textarea.tsx b/packages/ui/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..3c3e5d0
--- /dev/null
+++ b/packages/ui/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+ <textarea
+ data-slot="textarea"
+ className={cn(
+ "border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-none border bg-transparent px-2.5 py-2 text-xs transition-colors focus-visible:ring-1 aria-invalid:ring-1 md:text-xs placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Textarea };
diff --git a/packages/ui/src/components/user-avatar.tsx b/packages/ui/src/components/user-avatar.tsx
new file mode 100644
index 0000000..bbdb84c
--- /dev/null
+++ b/packages/ui/src/components/user-avatar.tsx
@@ -0,0 +1,23 @@
+import { useAuthStore } from "@/models/auth";
+import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "./ui/avatar";
+
+export function UserAvatar({
+ className,
+ ...props
+}: React.ComponentProps<typeof Avatar>) {
+ const authStore = useAuthStore();
+
+ if (!authStore.account) {
+ return null;
+ }
+
+ return (
+ <Avatar {...props}>
+ <AvatarImage
+ src={`https://minotar.net/helm/${authStore.account.username}/100.png`}
+ />
+ <AvatarFallback>{authStore.account.username.slice(0, 2)}</AvatarFallback>
+ <AvatarBadge />
+ </Avatar>
+ );
+}