diff options
| author | 2026-01-15 17:49:26 +0800 | |
|---|---|---|
| committer | 2026-01-15 17:49:26 +0800 | |
| commit | 32a9aceee42a2261b64f9e6effda522639576a5e (patch) | |
| tree | 4cae8d216c3093421addaa0450bc8004c537e373 /ui/src/components | |
| parent | ce4b0c2053d5d16f7091d74840d4a502401f1a4e (diff) | |
| parent | 31077dbd39a25eecd24a1dca0f8c9d1879265277 (diff) | |
| download | DropOut-32a9aceee42a2261b64f9e6effda522639576a5e.tar.gz DropOut-32a9aceee42a2261b64f9e6effda522639576a5e.zip | |
Merge pull request #30 from HsiangNianian/main
Diffstat (limited to 'ui/src/components')
| -rw-r--r-- | ui/src/components/BottomBar.svelte | 142 | ||||
| -rw-r--r-- | ui/src/components/CustomSelect.svelte | 136 | ||||
| -rw-r--r-- | ui/src/components/HomeView.svelte | 221 | ||||
| -rw-r--r-- | ui/src/components/LoginModal.svelte | 30 | ||||
| -rw-r--r-- | ui/src/components/ModLoaderSelector.svelte | 332 | ||||
| -rw-r--r-- | ui/src/components/ParticleBackground.svelte | 57 | ||||
| -rw-r--r-- | ui/src/components/SettingsView.svelte | 690 | ||||
| -rw-r--r-- | ui/src/components/Sidebar.svelte | 113 | ||||
| -rw-r--r-- | ui/src/components/StatusToast.svelte | 10 | ||||
| -rw-r--r-- | ui/src/components/VersionsView.svelte | 264 |
10 files changed, 1752 insertions, 243 deletions
diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte index dcad9e8..abb0b23 100644 --- a/ui/src/components/BottomBar.svelte +++ b/ui/src/components/BottomBar.svelte @@ -2,21 +2,55 @@ import { authState } from "../stores/auth.svelte"; import { gameState } from "../stores/game.svelte"; import { uiState } from "../stores/ui.svelte"; + import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte'; + + let isVersionDropdownOpen = $state(false); + let dropdownRef: HTMLDivElement; + + let versionOptions = $derived( + gameState.versions.length === 0 + ? [{ id: "loading", type: "loading", label: "Loading..." }] + : gameState.versions.map(v => ({ + ...v, + label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}` + })) + ); + + function selectVersion(id: string) { + if (id !== "loading") { + 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); + } + }); </script> <div - class="h-24 bg-zinc-900 border-t border-zinc-800 flex items-center px-8 justify-between z-20 shadow-2xl" + class="h-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" > - <div class="flex items-center gap-4"> + <!-- Account Area --> + <div class="flex items-center gap-6"> <div - class="flex items-center gap-4 cursor-pointer hover:opacity-80 transition-opacity" + class="group flex items-center gap-4 cursor-pointer" onclick={() => authState.openLoginModal()} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && authState.openLoginModal()} > <div - class="w-12 h-12 rounded bg-gradient-to-tr from-indigo-500 to-purple-500 shadow-lg flex items-center justify-center text-white font-bold text-xl overflow-hidden" + class="w-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 @@ -25,64 +59,98 @@ class="w-full h-full" /> {:else} - ? + <User size={20} class="text-zinc-400" /> {/if} </div> <div> - <div class="font-bold text-white text-lg"> - {authState.currentAccount ? authState.currentAccount.username : "Click to Login"} + <div class="font-bold dark:text-white text-gray-900 text-sm group-hover:text-black dark:group-hover:text-zinc-200 transition-colors"> + {authState.currentAccount ? authState.currentAccount.username : "Login Account"} </div> - <div class="text-xs text-zinc-400 flex items-center gap-1"> + <div class="text-[10px] uppercase tracking-wider dark:text-zinc-500 text-gray-500 flex items-center gap-2"> <span class="w-1.5 h-1.5 rounded-full {authState.currentAccount - ? 'bg-green-500' - : 'bg-zinc-500'}" + ? 'bg-emerald-500' + : 'bg-zinc-400'}" ></span> - {authState.currentAccount ? "Ready" : "Guest"} + {authState.currentAccount ? "Online" : "Guest"} </div> </div> </div> + + <div class="h-8 w-px dark:bg-white/10 bg-black/10"></div> + <!-- Console Toggle --> <button - class="ml-4 text-xs text-zinc-500 hover:text-zinc-300 transition" + class="text-xs font-mono dark:text-zinc-500 text-gray-500 dark:hover:text-white hover:text-black transition-colors flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-black/5 dark:hover:bg-white/5" onclick={() => uiState.toggleConsole()} > - {uiState.showConsole ? "Hide Logs" : "Show Logs"} + <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"> - <label - for="version-select" - class="text-xs text-zinc-500 mb-1 uppercase font-bold tracking-wider" - >Version</label - > - <select - id="version-select" - bind:value={gameState.selectedVersion} - class="bg-zinc-950 text-zinc-200 border border-zinc-700 rounded px-4 py-2 hover:border-zinc-500 transition-colors cursor-pointer outline-none focus:ring-1 focus:ring-indigo-500 w-48" - > - {#if gameState.versions.length === 0} - <option>Loading...</option> - {:else} - {#each gameState.versions as version} - <option value={version.id}>{version.id} ({version.type})</option - > - {/each} + <!-- Custom Version Dropdown --> + <div class="relative" bind:this={dropdownRef}> + <button + type="button" + onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen} + 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" + > + <span class="truncate"> + {#if gameState.versions.length === 0} + Loading... + {: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} + <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" + > + {#each versionOptions as version} + <button + type="button" + onclick={() => selectVersion(version.id)} + disabled={version.id === "loading"} + 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' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}" + > + <span class="truncate">{version.label}</span> + {#if version.id === gameState.selectedVersion} + <Check size={14} class="shrink-0 ml-2" /> + {/if} + </button> + {/each} + </div> {/if} - </select> + </div> </div> <button onclick={() => gameState.startGame()} - class="bg-green-600 hover:bg-green-500 text-white font-bold h-14 px-12 rounded transition-all transform active:scale-95 shadow-[0_0_15px_rgba(22,163,74,0.4)] hover:shadow-[0_0_25px_rgba(22,163,74,0.6)] flex flex-col items-center justify-center uppercase tracking-wider text-lg" + class="bg-emerald-600 hover:bg-emerald-500 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 - <span - class="text-[10px] font-normal opacity-80 normal-case tracking-normal" - >Click to launch</span - > + <Play size={24} fill="currentColor" /> + <span>Launch</span> </button> </div> </div> diff --git a/ui/src/components/CustomSelect.svelte b/ui/src/components/CustomSelect.svelte new file mode 100644 index 0000000..2e89c75 --- /dev/null +++ b/ui/src/components/CustomSelect.svelte @@ -0,0 +1,136 @@ +<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; + onchange?: (value: string) => void; + } + + let { + options, + value = $bindable(), + placeholder = "Select...", + disabled = false, + class: className = "", + onchange + }: Props = $props(); + + let isOpen = $state(false); + let containerRef: HTMLDivElement; + + let selectedOption = $derived(options.find(o => o.value === value)); + + function toggle() { + if (!disabled) { + isOpen = !isOpen; + } + } + + function select(option: Option) { + if (option.disabled) return; + value = option.value; + isOpen = false; + onchange?.(option.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 ? 'text-zinc-500' : ''}"> + {selectedOption?.label || placeholder} + </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" + > + {#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/ui/src/components/HomeView.svelte b/ui/src/components/HomeView.svelte index e876c14..7bb7e44 100644 --- a/ui/src/components/HomeView.svelte +++ b/ui/src/components/HomeView.svelte @@ -1,26 +1,205 @@ <script lang="ts"> - // No script needed currently, just static markup mostly + import { onMount } from 'svelte'; + import { gameState } from '../stores/game.svelte'; + import { releasesState } from '../stores/releases.svelte'; + import { Calendar, ExternalLink } from 'lucide-svelte'; + + type Props = { + mouseX: number; + mouseY: number; + }; + let { mouseX = 0, mouseY = 0 }: Props = $props(); + + 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, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + // 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/HsiangNianian/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, '<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> -<!-- Background Image - Using gradient fallback --> -<div - class="absolute inset-0 z-0 opacity-60 bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950 transition-transform duration-[10s] ease-linear group-hover:scale-105" -></div> -<div - class="absolute inset-0 z-0 bg-gradient-to-t from-zinc-900 via-transparent to-black/50" -></div> - -<div class="absolute bottom-24 left-8 z-10 p-4"> - <h1 - class="text-6xl font-black mb-2 tracking-tight text-white drop-shadow-lg" - > - MINECRAFT - </h1> - <div class="flex items-center gap-2 text-zinc-300"> - <span - class="bg-zinc-800 text-xs px-2 py-1 rounded border border-zinc-600" - >JAVA EDITION</span - > - <span class="text-lg">Release 1.20.4</span> +<div 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) --> + <div class="min-h-full flex flex-col justify-end p-12 pb-32"> + <!-- 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/ui/src/components/LoginModal.svelte b/ui/src/components/LoginModal.svelte index f1ac0d5..1886cd9 100644 --- a/ui/src/components/LoginModal.svelte +++ b/ui/src/components/LoginModal.svelte @@ -9,16 +9,16 @@ {#if authState.isLoginModalOpen} <div - class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4" + class="fixed inset-0 z-[60] flex items-center justify-center bg-white/80 dark:bg-black/80 backdrop-blur-sm p-4" > <div - class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200" + class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200" > <div class="flex justify-between items-center mb-6"> - <h2 class="text-2xl font-bold text-white">Login</h2> + <h2 class="text-2xl font-bold text-gray-900 dark:text-white">Login</h2> <button onclick={() => authState.closeLoginModal()} - class="text-zinc-500 hover:text-white transition group" + class="text-zinc-500 hover:text-black dark:hover:text-white transition group" > โ </button> @@ -28,7 +28,7 @@ <div class="space-y-4"> <button onclick={() => authState.startMicrosoftLogin()} - class="w-full flex items-center justify-center gap-3 bg-[#2F2F2F] hover:bg-[#3F3F3F] text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-500 transition-all group" + class="w-full flex items-center justify-center gap-3 bg-gray-100 hover:bg-gray-200 dark:bg-[#2F2F2F] dark:hover:bg-[#3F3F3F] text-gray-900 dark:text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-400 dark:hover:border-zinc-500 transition-all group" > <!-- Microsoft Logo SVG --> <svg @@ -49,10 +49,10 @@ <div class="relative py-2"> <div class="absolute inset-0 flex items-center"> - <div class="w-full border-t border-zinc-700"></div> + <div class="w-full border-t border-zinc-200 dark:border-zinc-700"></div> </div> <div class="relative flex justify-center text-xs uppercase"> - <span class="bg-zinc-900 px-2 text-zinc-500">OR</span> + <span class="bg-white dark:bg-zinc-900 px-2 text-zinc-400 dark:text-zinc-500">OR</span> </div> </div> @@ -61,12 +61,12 @@ type="text" bind:value={authState.offlineUsername} placeholder="Offline Username" - class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-white focus:border-indigo-500 outline-none" + class="w-full bg-gray-50 border-zinc-200 dark:bg-zinc-950 dark:border-zinc-700 rounded p-3 text-gray-900 dark:text-white focus:border-indigo-500 outline-none" onkeydown={(e) => e.key === "Enter" && authState.performOfflineLogin()} /> <button onclick={() => authState.performOfflineLogin()} - class="w-full bg-zinc-800 hover:bg-zinc-700 text-zinc-300 p-3 rounded font-medium transition-colors" + class="w-full bg-gray-200 hover:bg-gray-300 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 p-3 rounded font-medium transition-colors" > Offline Login </button> @@ -80,18 +80,18 @@ </div> {:else if authState.deviceCodeData} <div class="space-y-4"> - <p class="text-sm text-zinc-400">1. Go to this URL:</p> + <p class="text-sm text-gray-500 dark:text-zinc-400">1. Go to this URL:</p> <button onclick={() => authState.deviceCodeData && openLink(authState.deviceCodeData.verification_uri)} - class="text-indigo-400 hover:text-indigo-300 underline break-all font-mono text-sm" + class="text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 underline break-all font-mono text-sm" > {authState.deviceCodeData.verification_uri} </button> - <p class="text-sm text-zinc-400 mt-2">2. Enter this code:</p> + <p class="text-sm text-gray-500 dark:text-zinc-400 mt-2">2. Enter this code:</p> <div - class="bg-zinc-950 p-4 rounded border border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors" + class="bg-gray-50 dark:bg-zinc-950 p-4 rounded border border-zinc-200 dark:border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors dark:text-white text-gray-900" role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && navigator.clipboard.writeText(authState.deviceCodeData?.user_code || "")} @@ -106,8 +106,8 @@ <div class="pt-6 space-y-3"> <div class="flex flex-col items-center gap-3"> - <div class="animate-spin rounded-full h-6 w-6 border-2 border-zinc-600 border-t-indigo-500"></div> - <span class="text-sm text-zinc-400 font-medium break-all text-center">{authState.msLoginStatus}</span> + <div class="animate-spin rounded-full h-6 w-6 border-2 border-zinc-300 dark:border-zinc-600 border-t-indigo-500"></div> + <span class="text-sm text-gray-600 dark:text-zinc-400 font-medium break-all text-center">{authState.msLoginStatus}</span> </div> <p class="text-xs text-zinc-600">This window will update automatically.</p> </div> diff --git a/ui/src/components/ModLoaderSelector.svelte b/ui/src/components/ModLoaderSelector.svelte new file mode 100644 index 0000000..cb949c5 --- /dev/null +++ b/ui/src/components/ModLoaderSelector.svelte @@ -0,0 +1,332 @@ +<script lang="ts"> + import { invoke } from "@tauri-apps/api/core"; + import type { + FabricGameVersion, + FabricLoaderVersion, + ForgeVersion, + ModLoaderType, + } from "../types"; + import { Loader2, Download, AlertCircle, Check, ChevronDown } from 'lucide-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 error = $state<string | null>(null); + + // 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); + + // Load mod loader versions when game version changes + $effect(() => { + if (selectedGameVersion && selectedLoader !== "vanilla") { + loadModLoaderVersions(); + } + }); + + async function loadModLoaderVersions() { + isLoading = true; + error = null; + + try { + if (selectedLoader === "fabric") { + const loaders = await invoke<any[]>("get_fabric_loaders_for_version", { + gameVersion: selectedGameVersion, + }); + fabricLoaders = loaders.map((l) => l.loader); + if (fabricLoaders.length > 0) { + // Select first stable version or first available + const stable = fabricLoaders.find((l) => l.stable); + selectedFabricLoader = stable?.version || fabricLoaders[0].version; + } + } else if (selectedLoader === "forge") { + forgeVersions = await invoke<ForgeVersion[]>( + "get_forge_versions_for_game", + { + gameVersion: selectedGameVersion, + } + ); + if (forgeVersions.length > 0) { + // Select recommended version first, then latest + const recommended = forgeVersions.find((v) => v.recommended); + const latest = forgeVersions.find((v) => v.latest); + selectedForgeVersion = + recommended?.version || latest?.version || forgeVersions[0].version; + } + } + } catch (e) { + error = `Failed to load ${selectedLoader} versions: ${e}`; + console.error(e); + } finally { + isLoading = false; + } + } + + async function installModLoader() { + if (!selectedGameVersion) { + error = "Please select a Minecraft version first"; + return; + } + + isLoading = true; + error = null; + + try { + if (selectedLoader === "fabric" && selectedFabricLoader) { + const result = await invoke<any>("install_fabric", { + gameVersion: selectedGameVersion, + loaderVersion: selectedFabricLoader, + }); + onInstall(result.id); + } else if (selectedLoader === "forge" && selectedForgeVersion) { + const result = await invoke<any>("install_forge", { + gameVersion: selectedGameVersion, + forgeVersion: selectedForgeVersion, + }); + onInstall(result.id); + } + } catch (e) { + error = `Failed to install ${selectedLoader}: ${e}`; + console.error(e); + } finally { + isLoading = false; + } + } + + function onLoaderChange(loader: ModLoaderType) { + selectedLoader = loader; + error = null; + if (loader !== "vanilla" && selectedGameVersion) { + loadModLoaderVersions(); + } + } + + 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)} + > + {loader} + </button> + {/each} + </div> + + <!-- Content Area --> + <div class="min-h-[100px] flex flex-col justify-center"> + {#if selectedLoader === "vanilla"} + <div class="text-center p-6 border border-dashed border-zinc-200 dark:border-white/10 rounded-sm text-zinc-500 text-sm"> + Standard Minecraft experience. No modifications. + </div> + + {:else 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 base Minecraft version first.</span> + </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"> + <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} + 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" + > + <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-black dark:bg-white text-white dark:text-black py-2.5 px-4 rounded-sm font-bold text-sm transition-all hover:opacity-90 flex items-center justify-center gap-2" + onclick={installModLoader} + disabled={isLoading || !selectedFabricLoader} + > + <Download size={16} /> + Install Fabric + </button> + </div> + + {:else if selectedLoader === "forge"} + <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300"> + {#if forgeVersions.length === 0} + <div class="text-center p-4 text-sm text-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} + 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" + > + <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-black dark:bg-white text-white dark:text-black py-2.5 px-4 rounded-sm font-bold text-sm transition-all hover:opacity-90 flex items-center justify-center gap-2" + onclick={installModLoader} + disabled={isLoading || !selectedForgeVersion} + > + <Download size={16} /> + Install Forge + </button> + {/if} + </div> + {/if} + </div> +</div> diff --git a/ui/src/components/ParticleBackground.svelte b/ui/src/components/ParticleBackground.svelte new file mode 100644 index 0000000..080f1f2 --- /dev/null +++ b/ui/src/components/ParticleBackground.svelte @@ -0,0 +1,57 @@ +<script lang="ts"> + import { onMount, onDestroy } from "svelte"; + import { ConstellationEffect } from "../lib/effects/ConstellationEffect"; + import { SaturnEffect } from "../lib/effects/SaturnEffect"; + import { settingsState } from "../stores/settings.svelte"; + + let canvas: HTMLCanvasElement; + let activeEffect: any; + + function loadEffect() { + if (activeEffect) { + activeEffect.destroy(); + } + + if (!canvas) return; + + if (settingsState.settings.active_effect === "saturn") { + activeEffect = new SaturnEffect(canvas); + } else { + activeEffect = new ConstellationEffect(canvas); + } + + // Ensure correct size immediately + activeEffect.resize(window.innerWidth, window.innerHeight); + } + + $effect(() => { + const _ = settingsState.settings.active_effect; + if (canvas) { + loadEffect(); + } + }); + + onMount(() => { + const resizeObserver = new ResizeObserver(() => { + if (canvas && activeEffect) { + activeEffect.resize(window.innerWidth, window.innerHeight); + } + }); + + resizeObserver.observe(document.body); + + return () => { + resizeObserver.disconnect(); + if (activeEffect) activeEffect.destroy(); + }; + }); + + onDestroy(() => { + if (activeEffect) activeEffect.destroy(); + }); +</script> + +<canvas + bind:this={canvas} + class="absolute inset-0 z-0 pointer-events-none" +></canvas> diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte index 801970b..76d441b 100644 --- a/ui/src/components/SettingsView.svelte +++ b/ui/src/components/SettingsView.svelte @@ -1,153 +1,685 @@ <script lang="ts"> + import { open } from "@tauri-apps/plugin-dialog"; import { settingsState } from "../stores/settings.svelte"; + import CustomSelect from "./CustomSelect.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)" } + ]; + + async function selectBackground() { + try { + const selected = await open({ + multiple: false, + filters: [ + { + name: "Images", + extensions: ["png", "jpg", "jpeg", "webp", "gif"], + }, + ], + }); + + if (selected && typeof selected === "string") { + settingsState.settings.custom_background_path = selected; + settingsState.saveSettings(); + } + } catch (e) { + console.error("Failed to select background:", e); + } + } + + function clearBackground() { + settingsState.settings.custom_background_path = undefined; + settingsState.saveSettings(); + } </script> -<div class="p-8 bg-zinc-900 h-full overflow-y-auto"> - <h2 class="text-3xl font-bold mb-8">Settings</h2> +<div class="h-full flex flex-col p-6 overflow-hidden"> + <div class="flex items-center justify-between mb-6"> + <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">Settings</h2> + </div> - <div class="space-y-6 max-w-2xl"> - <!-- Java Path --> - <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700"> - <label - for="java-path" - class="block text-sm font-bold text-zinc-400 mb-2 uppercase tracking-wide" - >Java Executable Path</label - > - <div class="flex gap-2"> - <input - id="java-path" - bind:value={settingsState.settings.java_path} - type="text" - class="bg-zinc-950 text-white flex-1 p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none font-mono text-sm" - placeholder="e.g. java, /usr/bin/java" - /> - <button - onclick={() => settingsState.detectJava()} - disabled={settingsState.isDetectingJava} - class="bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-white px-4 py-2 rounded transition-colors whitespace-nowrap" - > - {settingsState.isDetectingJava ? "Detecting..." : "Auto Detect"} - </button> + <div class="flex-1 overflow-y-auto pr-2 space-y-6 custom-scrollbar pb-10"> + + <!-- Appearance / Background --> + <div class="dark:bg-[#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-xs text-zinc-400 uppercase font-bold">Detected Java Installations:</p> + <p class="text-[10px] text-white/30 uppercase font-bold tracking-wider">Detected Installations</p> {#each settingsState.javaInstallations as java} <button onclick={() => settingsState.selectJava(java.path)} - class="w-full text-left p-3 bg-zinc-950 rounded border transition-colors {settingsState.settings.java_path === java.path ? 'border-indigo-500 bg-indigo-950/30' : 'border-zinc-700 hover:border-zinc-500'}" + class="w-full text-left p-3 rounded-lg border transition-all duration-200 group + {settingsState.settings.java_path === java.path + ? 'bg-indigo-500/20 border-indigo-500/30' + : 'bg-black/20 border-white/5 hover:bg-white/5 hover:border-white/10'}" > <div class="flex justify-between items-center"> <div> - <span class="text-white font-mono text-sm">{java.version}</span> - <span class="text-zinc-500 text-xs ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span> + <span class="text-white font-mono text-xs font-bold">{java.version}</span> + <span class="text-white/40 text-[10px] ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span> </div> {#if settingsState.settings.java_path === java.path} - <span class="text-indigo-400 text-xs">Selected</span> + <span class="text-indigo-300 text-[10px] font-bold uppercase tracking-wider">Selected</span> {/if} </div> - <div class="text-zinc-500 text-xs font-mono truncate mt-1">{java.path}</div> + <div class="text-white/30 text-[10px] font-mono truncate mt-1 group-hover:text-white/50">{java.path}</div> </button> {/each} </div> {/if} - - <p class="text-xs text-zinc-500 mt-2"> - The command or path to the Java Runtime Environment. Click "Auto Detect" to find installed Java versions. - </p> + </div> </div> <!-- Memory --> - <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700"> - <h3 - class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide" - >Memory Allocation (RAM)</h3> - + <div class="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-xs text-zinc-500 mb-1" - >Minimum (MB)</label - > + <label for="min-memory" class="block text-sm font-medium text-white/70 mb-2">Minimum (MB)</label> <input id="min-memory" bind:value={settingsState.settings.min_memory} type="number" - class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none" + class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors" /> </div> <div> - <label for="max-memory" class="block text-xs text-zinc-500 mb-1" - >Maximum (MB)</label - > + <label for="max-memory" class="block text-sm font-medium text-white/70 mb-2">Maximum (MB)</label> <input id="max-memory" bind:value={settingsState.settings.max_memory} type="number" - class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none" + class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors" /> </div> </div> </div> <!-- Resolution --> - <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700"> - <h3 - class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide" - >Game Window Size</h3> + <div class="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-xs text-zinc-500 mb-1">Width</label> + <label for="window-width" class="block text-sm font-medium text-white/70 mb-2">Width</label> <input id="window-width" bind:value={settingsState.settings.width} type="number" - class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none" + class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors" /> </div> <div> - <label for="window-height" class="block text-xs text-zinc-500 mb-1">Height</label> + <label for="window-height" class="block text-sm font-medium text-white/70 mb-2">Height</label> <input id="window-height" bind:value={settingsState.settings.height} type="number" - class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none" + class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors" /> </div> </div> </div> <!-- Download Settings --> - <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700"> - <h3 - class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide" - >Download Settings</h3> - <div> - <label for="download-threads" class="block text-xs text-zinc-500 mb-1" - >Concurrent Download Threads</label - > - <input - id="download-threads" - bind:value={settingsState.settings.download_threads} - type="number" - min="1" - max="128" - step="1" - class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none" - /> - <p class="text-xs text-zinc-500 mt-2"> - Number of concurrent download threads (1-128). Higher values increase download speed but use more bandwidth and system resources. Default: 32 - </p> - </div> + <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> - <div class="pt-4"> + <!-- 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> + + <div class="pt-4 flex justify-end"> <button onclick={() => settingsState.saveSettings()} - class="bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-8 rounded shadow-lg transition-transform active:scale-95" + class="bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-500 hover:to-green-500 text-white font-bold py-3 px-8 rounded-xl shadow-lg shadow-emerald-500/20 transition-all hover:scale-105 active:scale-95" > Save Settings </button> </div> </div> </div> + +<!-- 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/ui/src/components/Sidebar.svelte b/ui/src/components/Sidebar.svelte index a4f4e35..1d7cc16 100644 --- a/ui/src/components/Sidebar.svelte +++ b/ui/src/components/Sidebar.svelte @@ -1,66 +1,89 @@ <script lang="ts"> import { uiState } from '../stores/ui.svelte'; + import { Home, Package, Settings } from 'lucide-svelte'; </script> <aside - class="w-20 lg:w-64 bg-zinc-950 flex flex-col items-center lg:items-start transition-all duration-300 border-r border-zinc-800 shrink-0" + class="w-20 lg:w-64 dark:bg-[#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-20 w-full flex items-center justify-center lg:justify-start lg:px-6 border-b border-zinc-800/50" + class="h-16 w-full flex items-center justify-center lg:justify-start lg:px-6 mb-6" > - <!-- Icon Logo (Visible on small) --> - <div - class="lg:hidden text-2xl font-black bg-clip-text text-transparent bg-gradient-to-tr from-indigo-400 to-purple-400" - > - D + <!-- 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 (Visible on large) --> + <!-- Full Logo (Large) --> <div - class="hidden lg:block font-bold text-xl tracking-wider text-indigo-400" + class="hidden lg:flex items-center gap-3 font-bold text-xl tracking-tighter dark:text-white text-black" > - DROP<span class="text-white">OUT</span> + <!-- 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> - <nav class="flex-1 w-full flex flex-col gap-2 p-3"> - <button - class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView === - 'home' - ? 'bg-zinc-800/80 text-white' - : 'text-zinc-400'} transition-all relative" - onclick={() => uiState.setView("home")} - > - <span class="text-xl relative z-10">๐ </span> - <span - class="hidden lg:block font-medium relative z-10 transition-opacity" - >Home</span + <!-- Navigation --> + <nav class="flex-1 w-full flex flex-col gap-1 px-3"> + <!-- Nav Item Helper --> + {#snippet navItem(view, Icon, label)} + <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)} > - </button> - <button - class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView === - 'versions' - ? 'bg-zinc-800/80 text-white' - : 'text-zinc-400'} transition-all" - onclick={() => uiState.setView("versions")} - > - <span class="text-xl">๐ฆ</span> - <span class="hidden lg:block font-medium">Versions</span> - </button> - <button - class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView === - 'settings' - ? 'bg-zinc-800/80 text-white' - : 'text-zinc-400'} transition-all" - onclick={() => uiState.setView("settings")} - > - <span class="text-xl">โ๏ธ</span> - <span class="hidden lg:block font-medium">Settings</span> - </button> + <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('versions', Package, 'Versions')} + {@render navItem('settings', Settings, 'Settings')} </nav> + <!-- Footer Info --> <div - class="p-4 w-full border-t border-zinc-800 flex justify-center lg:justify-start" + class="p-4 w-full flex justify-center lg:justify-start lg:px-6 opacity-40 hover:opacity-100 transition-opacity" > - <div class="text-xs text-zinc-600 font-mono">v{uiState.appVersion}</div> + <div class="text-[10px] font-mono text-zinc-500 uppercase tracking-wider">v{uiState.appVersion}</div> </div> </aside> diff --git a/ui/src/components/StatusToast.svelte b/ui/src/components/StatusToast.svelte index 0d68778..4c981c7 100644 --- a/ui/src/components/StatusToast.svelte +++ b/ui/src/components/StatusToast.svelte @@ -5,19 +5,19 @@ {#if uiState.status !== "Ready"} {#key uiState.status} <div - class="absolute top-12 right-12 bg-zinc-800/90 backdrop-blur border border-zinc-600 p-4 rounded-lg shadow-2xl max-w-sm animate-in fade-in slide-in-from-top-4 duration-300 z-50 group" + 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-400 uppercase font-bold">Status</div> + <div class="text-xs text-zinc-500 dark:text-zinc-400 uppercase font-bold">Status</div> <button onclick={() => uiState.setStatus("Ready")} - class="text-zinc-500 hover:text-white transition -mt-1 -mr-1 p-1" + 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">{uiState.status}</div> - <div class="w-full bg-zinc-700/50 h-1 rounded-full overflow-hidden"> + <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> diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte index 98261b8..99cc296 100644 --- a/ui/src/components/VersionsView.svelte +++ b/ui/src/components/VersionsView.svelte @@ -1,55 +1,237 @@ <script lang="ts"> + import { invoke } from "@tauri-apps/api/core"; import { gameState } from "../stores/game.svelte"; + import ModLoaderSelector from "./ModLoaderSelector.svelte"; let searchQuery = $state(""); let normalizedQuery = $derived( searchQuery.trim().toLowerCase().replace(/ใ/g, ".") ); - let filteredVersions = $derived( - gameState.versions.filter((v) => - v.id.toLowerCase().includes(normalizedQuery) - ) - ); + // Filter by version type + let typeFilter = $state<"all" | "release" | "snapshot" | "modded">("all"); + + // Installed modded versions + let installedFabricVersions = $state<string[]>([]); + let isLoadingModded = $state(false); + + // Load installed modded versions + async function loadInstalledModdedVersions() { + isLoadingModded = true; + try { + installedFabricVersions = await invoke<string[]>( + "list_installed_fabric_versions" + ); + } catch (e) { + console.error("Failed to load installed fabric versions:", e); + } finally { + isLoadingModded = false; + } + } + + // Load on mount + $effect(() => { + loadInstalledModdedVersions(); + }); + + // Combined versions list (vanilla + modded) + let allVersions = $derived(() => { + const moddedVersions = installedFabricVersions.map((id) => ({ + id, + type: "fabric", + url: "", + time: "", + releaseTime: new Date().toISOString(), + })); + return [...moddedVersions, ...gameState.versions]; + }); + + let filteredVersions = $derived(() => { + let versions = allVersions(); + + // Apply type filter + if (typeFilter === "release") { + versions = versions.filter((v) => v.type === "release"); + } else if (typeFilter === "snapshot") { + versions = versions.filter((v) => v.type === "snapshot"); + } else if (typeFilter === "modded") { + versions = versions.filter( + (v) => v.type === "fabric" || v.type === "forge" + ); + } + + // Apply search filter + if (normalizedQuery.length > 0) { + versions = versions.filter((v) => + v.id.toLowerCase().includes(normalizedQuery) + ); + } + + return versions; + }); + + function getVersionBadge(type: string) { + switch (type) { + case "release": + return { text: "Release", class: "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-300 dark:border-emerald-500/30" }; + case "snapshot": + return { text: "Snapshot", class: "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/20 dark:text-amber-300 dark:border-amber-500/30" }; + case "fabric": + return { text: "Fabric", class: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:border-indigo-500/30" }; + case "forge": + return { text: "Forge", class: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-300 dark:border-orange-500/30" }; + default: + return { text: type, class: "bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-zinc-500/20 dark:text-zinc-300 dark:border-zinc-500/30" }; + } + } + + function handleModLoaderInstall(versionId: string) { + // Refresh the installed versions list + loadInstalledModdedVersions(); + // Select the newly installed version + gameState.selectedVersion = versionId; + } + + // Get the base Minecraft version from selected version (for mod loader selector) + let selectedBaseVersion = $derived(() => { + const selected = gameState.selectedVersion; + if (!selected) return ""; + + // If it's a modded version, extract the base version + if (selected.startsWith("fabric-loader-")) { + // Format: fabric-loader-X.X.X-1.20.4 + const parts = selected.split("-"); + return parts[parts.length - 1]; + } + if (selected.includes("-forge-")) { + // Format: 1.20.4-forge-49.0.38 + return selected.split("-forge-")[0]; + } + + // Check if it's a valid vanilla version + const version = gameState.versions.find((v) => v.id === selected); + return version ? selected : ""; + }); </script> -<div class="p-8 h-full overflow-y-auto bg-zinc-900"> - <h2 class="text-3xl font-bold mb-6">Versions</h2> - - <input - type="text" - placeholder="Search versions..." - class="w-full p-3 mb-4 bg-zinc-800 border border-zinc-700 rounded text-white focus:outline-none focus:border-green-500 transition-colors" - bind:value={searchQuery} - /> - - <div class="grid gap-2"> - {#if gameState.versions.length === 0} - <div class="text-zinc-500">Loading versions...</div> - {:else if filteredVersions.length === 0 && normalizedQuery.length > 0} - <div class="text-zinc-500">No versions found matching "{searchQuery}"</div> - {:else} - {#each filteredVersions as version} - <button - class="flex items-center justify-between p-4 bg-zinc-800 rounded hover:bg-zinc-700 transition text-left border border-zinc-700 {gameState.selectedVersion === - version.id - ? 'border-green-500 bg-zinc-800/80 ring-1 ring-green-500' - : ''}" - onclick={() => (gameState.selectedVersion = version.id)} - > - <div> - <div class="font-bold font-mono text-lg">{version.id}</div> - <div class="text-xs text-zinc-400 capitalize"> - {version.type} โข {new Date( - version.releaseTime - ).toLocaleDateString()} - </div> +<div class="h-full flex flex-col p-6 overflow-hidden"> + <div class="flex items-center justify-between mb-6"> + <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/60">Version Manager</h2> + <div class="text-sm dark:text-white/40 text-black/50">Select a version to play or modify</div> + </div> + + <div class="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 overflow-hidden"> + <!-- Left: Version List --> + <div class="lg:col-span-2 flex flex-col gap-4 overflow-hidden"> + <!-- Search and Filters (Glass Bar) --> + <div class="flex gap-3"> + <div class="relative flex-1"> + <span class="absolute left-3 top-1/2 -translate-y-1/2 dark:text-white/30 text-black/30">๐</span> + <input + type="text" + placeholder="Search versions..." + class="w-full pl-9 pr-4 py-3 bg-white/60 dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-xl dark:text-white text-gray-900 placeholder-black/30 dark:placeholder-white/30 focus:outline-none focus:border-indigo-500/50 dark:focus:bg-black/40 focus:bg-white/80 transition-all backdrop-blur-sm" + bind:value={searchQuery} + /> + </div> + </div> + + <!-- Type Filter Tabs (Glass Caps) --> + <div class="flex p-1 bg-white/60 dark:bg-black/20 rounded-xl border border-black/5 dark:border-white/5"> + {#each ['all', 'release', 'snapshot', 'modded'] as filter} + <button + class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 capitalize + {typeFilter === filter + ? 'bg-white shadow-sm border border-black/5 dark:bg-white/10 dark:text-white dark:shadow-lg dark:border-white/10 text-black' + : 'text-black/40 dark:text-white/40 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}" + onclick={() => (typeFilter = filter as any)} + > + {filter} + </button> + {/each} + </div> + + <!-- Version List SCROLL --> + <div class="flex-1 overflow-y-auto pr-2 space-y-2 custom-scrollbar"> + {#if gameState.versions.length === 0} + <div class="flex items-center justify-center h-40 dark:text-white/30 text-black/30 italic animate-pulse"> + Fetching manifest... </div> - {#if gameState.selectedVersion === version.id} - <div class="text-green-500 font-bold text-sm">SELECTED</div> + {:else if filteredVersions().length === 0} + <div class="flex flex-col items-center justify-center 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"> + <span + class="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border {badge.class}" + > + {badge.text} + </span> + <div> + <div class="font-bold font-mono text-lg tracking-tight {isSelected ? 'text-black dark:text-white' : 'text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white'}"> + {version.id} + </div> + {#if version.releaseTime && version.type !== "fabric" && version.type !== "forge"} + <div class="text-xs dark:text-white/30 text-black/30"> + {new Date(version.releaseTime).toLocaleDateString()} + </div> + {/if} + </div> + </div> + + {#if isSelected} + <div class="relative z-10 text-indigo-500 dark:text-indigo-400"> + <span class="text-lg">Selected</span> + </div> + {/if} + </button> + {/each} + {/if} + </div> + </div> + + <!-- Right: Mod Loader Panel --> + <div class="flex flex-col gap-4"> + <!-- Selected Version Info Card --> + <div class="bg-gradient-to-br from-white/40 to-white/20 dark:from-white/10 dark:to-white/5 p-6 rounded-2xl border border-black/5 dark:border-white/10 backdrop-blur-md relative overflow-hidden group"> + <div class="absolute top-0 right-0 p-8 bg-indigo-500/20 blur-[60px] rounded-full group-hover:bg-indigo-500/30 transition-colors"></div> + + <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-2 relative z-10">Current Selection</h3> + {#if gameState.selectedVersion} + <p class="font-mono text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/70 relative z-10 truncate"> + {gameState.selectedVersion} + </p> + {:else} + <p class="dark:text-white/20 text-black/20 italic relative z-10">None selected</p> {/if} - </button> - {/each} - {/if} + </div> + + <!-- Mod Loader Selector Card --> + <div class="bg-white/60 dark:bg-black/20 p-4 rounded-2xl border border-black/5 dark:border-white/5 backdrop-blur-sm flex-1 flex flex-col"> + <ModLoaderSelector + selectedGameVersion={selectedBaseVersion()} + onInstall={handleModLoaderInstall} + /> + </div> + + </div> </div> </div> + |