aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/ui/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/components')
-rw-r--r--ui/src/components/BottomBar.svelte97
-rw-r--r--ui/src/components/CustomSelect.svelte136
-rw-r--r--ui/src/components/ModLoaderSelector.svelte150
-rw-r--r--ui/src/components/SettingsView.svelte33
4 files changed, 362 insertions, 54 deletions
diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte
index 198d4e6..abb0b23 100644
--- a/ui/src/components/BottomBar.svelte
+++ b/ui/src/components/BottomBar.svelte
@@ -2,7 +2,39 @@
import { authState } from "../stores/auth.svelte";
import { gameState } from "../stores/game.svelte";
import { uiState } from "../stores/ui.svelte";
- import { Terminal, ChevronDown, Play, User } from 'lucide-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
@@ -60,23 +92,56 @@
<!-- Action Area -->
<div class="flex items-center gap-4">
<div class="flex flex-col items-end mr-2">
- <div class="relative group">
- <select
- id="version-select"
- bind:value={gameState.selectedVersion}
- class="appearance-none dark:bg-zinc-900 bg-zinc-50 dark:text-white text-gray-900 border dark:border-white/10 border-black/10 rounded-sm pl-4 pr-10 py-2 dark:hover:border-white/30 hover:border-black/30 transition-all cursor-pointer outline-none focus:ring-1 focus:ring-zinc-500 w-56 text-sm font-mono"
+ <!-- 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"
>
- {#if gameState.versions.length === 0}
- <option>Loading...</option>
- {:else}
- {#each gameState.versions as version}
- <option value={version.id}>{version.id} {version.type !== 'release' ? `(${version.type})` : ''}</option>
+ <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}
- {/if}
- </select>
- <div class="absolute right-3 top-1/2 -translate-y-1/2 dark:text-white/20 text-black/20 pointer-events-none">
- <ChevronDown size={14} />
- </div>
+ </div>
+ {/if}
</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/ModLoaderSelector.svelte b/ui/src/components/ModLoaderSelector.svelte
index d0c1b59..cb949c5 100644
--- a/ui/src/components/ModLoaderSelector.svelte
+++ b/ui/src/components/ModLoaderSelector.svelte
@@ -6,7 +6,7 @@
ForgeVersion,
ModLoaderType,
} from "../types";
- import { Loader2, Download, AlertCircle, Check } from 'lucide-svelte';
+ import { Loader2, Download, AlertCircle, Check, ChevronDown } from 'lucide-svelte';
interface Props {
selectedGameVersion: string;
@@ -23,10 +23,15 @@
// 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(() => {
@@ -111,6 +116,44 @@
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">
@@ -163,18 +206,48 @@
<label for="fabric-loader-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2"
>Loader Version</label
>
- <div class="relative">
- <select
- id="fabric-loader-select"
- class="w-full bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md px-4 py-2.5 pr-10 text-sm focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 text-gray-900 dark:text-white transition-colors cursor-pointer hover:border-zinc-400 dark:hover:border-zinc-600"
- bind:value={selectedFabricLoader}
+ <!-- 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"
>
- {#each fabricLoaders as loader}
- <option value={loader.version}>
- {loader.version} {loader.stable ? "(stable)" : ""}
- </option>
- {/each}
- </select>
+ <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>
@@ -199,19 +272,48 @@
<label for="forge-version-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2"
>Forge Version</label
>
- <div class="relative">
- <select
- id="forge-version-select"
- class="w-full bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md px-4 py-2.5 pr-10 text-sm focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 text-gray-900 dark:text-white transition-colors cursor-pointer hover:border-zinc-400 dark:hover:border-zinc-600"
- bind:value={selectedForgeVersion}
+ <!-- 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"
>
- {#each forgeVersions as version}
- <option value={version.version}>
- {version.version}
- {version.recommended ? " (Recommended)" : ""}
- </option>
- {/each}
- </select>
+ <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>
diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte
index 732f857..76d441b 100644
--- a/ui/src/components/SettingsView.svelte
+++ b/ui/src/components/SettingsView.svelte
@@ -1,11 +1,22 @@
<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({
@@ -116,15 +127,12 @@
<h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="theme-effect-label">Theme Effect</h4>
<p class="text-xs dark:text-white/40 text-black/50 mt-1">Select the active visual theme.</p>
</div>
- <select
- aria-labelledby="theme-effect-label"
+ <CustomSelect
+ options={effectOptions}
bind:value={settingsState.settings.active_effect}
onchange={() => settingsState.saveSettings()}
- class="dark:bg-zinc-900 bg-white dark:text-white text-black text-xs px-3 py-2 pr-8 rounded-lg border dark:border-zinc-700 border-gray-300 outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 cursor-pointer hover:border-zinc-600 transition-colors"
- >
- <option value="saturn">Saturn (Saturn)</option>
- <option value="constellation">Network (Constellation)</option>
- </select>
+ class="w-52"
+ />
</div>
{/if}
@@ -308,14 +316,11 @@
<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>
- <select
- id="log-service"
+ <CustomSelect
+ options={logServiceOptions}
bind:value={settingsState.settings.log_upload_service}
- class="dark:bg-zinc-900 bg-white dark:text-white text-black w-full px-4 py-3 pr-10 rounded-xl border dark:border-zinc-700 border-gray-300 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 outline-none cursor-pointer hover:border-zinc-600 transition-colors"
- >
- <option value="paste.rs">paste.rs (Free, No Account)</option>
- <option value="pastebin.com">pastebin.com (Requires API Key)</option>
- </select>
+ class="w-full"
+ />
</div>
{#if settingsState.settings.log_upload_service === 'pastebin.com'}