aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/ui/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/components')
-rw-r--r--ui/src/components/BottomBar.svelte142
-rw-r--r--ui/src/components/CustomSelect.svelte136
-rw-r--r--ui/src/components/HomeView.svelte221
-rw-r--r--ui/src/components/LoginModal.svelte30
-rw-r--r--ui/src/components/ModLoaderSelector.svelte332
-rw-r--r--ui/src/components/ParticleBackground.svelte57
-rw-r--r--ui/src/components/SettingsView.svelte690
-rw-r--r--ui/src/components/Sidebar.svelte113
-rw-r--r--ui/src/components/StatusToast.svelte10
-rw-r--r--ui/src/components/VersionsView.svelte264
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, "&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/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>
+