aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui
diff options
context:
space:
mode:
author苏向夜 <46275354+fu050409@users.noreply.github.com>2026-02-25 02:06:07 +0800
committerGitHub <noreply@github.com>2026-02-25 02:06:07 +0800
commit78ac61904d78d558d092eff08c9f261cbdb187e8 (patch)
tree96f68d1f1554ee3a0532793afaaa52b0c73dcbec /packages/ui
parent8ff3af6cb908fd824b512379dd21ed4f595ab6bb (diff)
parent329734b23957b84cde2af459fa61c7385fb5b5f1 (diff)
downloadDropOut-78ac61904d78d558d092eff08c9f261cbdb187e8.tar.gz
DropOut-78ac61904d78d558d092eff08c9f261cbdb187e8.zip
feat(ui): partial react rewrite (#77)
## Summary by Sourcery Export backend data structures to TypeScript for the new React-based UI and update CI to build additional targets. New Features: - Generate TypeScript definitions for core backend structs and enums used by the UI. - Now use our own Azure app(_DropOut_) to finish the authorize process. Enhancements: - Annotate existing Rust models with ts-rs metadata to control exported TypeScript shapes, including tagged enums and opaque JSON fields. Build: - Add ts-rs as a dependency for generating TypeScript bindings from Rust types. CI: - Extend the Semifold CI workflow to run on the dev branch and build additional Linux musl and Windows GNU targets using cross where needed.
Diffstat (limited to 'packages/ui')
-rw-r--r--packages/ui/CHANGELOG.md7
-rw-r--r--packages/ui/README.md47
-rw-r--r--packages/ui/components.json23
-rw-r--r--packages/ui/index.html8
-rw-r--r--packages/ui/package.json54
-rw-r--r--packages/ui/pnpm-lock.yaml1363
-rw-r--r--packages/ui/public/icon.svg50
-rw-r--r--packages/ui/public/vite.svg1
-rw-r--r--packages/ui/src/App.svelte217
-rw-r--r--packages/ui/src/app.css167
-rw-r--r--packages/ui/src/assets/svelte.svg1
-rw-r--r--packages/ui/src/client.ts400
-rw-r--r--packages/ui/src/components/AssistantView.svelte436
-rw-r--r--packages/ui/src/components/BottomBar.svelte250
-rw-r--r--packages/ui/src/components/ConfigEditorModal.svelte369
-rw-r--r--packages/ui/src/components/CustomSelect.svelte173
-rw-r--r--packages/ui/src/components/HomeView.svelte271
-rw-r--r--packages/ui/src/components/InstanceCreationModal.svelte485
-rw-r--r--packages/ui/src/components/InstanceEditorModal.svelte439
-rw-r--r--packages/ui/src/components/InstancesView.svelte259
-rw-r--r--packages/ui/src/components/LoginModal.svelte126
-rw-r--r--packages/ui/src/components/ModLoaderSelector.svelte455
-rw-r--r--packages/ui/src/components/ParticleBackground.svelte70
-rw-r--r--packages/ui/src/components/SettingsView.svelte1217
-rw-r--r--packages/ui/src/components/Sidebar.svelte91
-rw-r--r--packages/ui/src/components/StatusToast.svelte42
-rw-r--r--packages/ui/src/components/VersionsView.svelte511
-rw-r--r--packages/ui/src/components/bottom-bar.tsx231
-rw-r--r--packages/ui/src/components/config-editor.tsx111
-rw-r--r--packages/ui/src/components/download-monitor.tsx62
-rw-r--r--packages/ui/src/components/game-console.tsx290
-rw-r--r--packages/ui/src/components/instance-creation-modal.tsx552
-rw-r--r--packages/ui/src/components/instance-editor-modal.tsx548
-rw-r--r--packages/ui/src/components/login-modal.tsx188
-rw-r--r--packages/ui/src/components/particle-background.tsx63
-rw-r--r--packages/ui/src/components/sidebar.tsx185
-rw-r--r--packages/ui/src/components/ui/avatar.tsx107
-rw-r--r--packages/ui/src/components/ui/badge.tsx52
-rw-r--r--packages/ui/src/components/ui/button.tsx56
-rw-r--r--packages/ui/src/components/ui/card.tsx103
-rw-r--r--packages/ui/src/components/ui/checkbox.tsx27
-rw-r--r--packages/ui/src/components/ui/dialog.tsx155
-rw-r--r--packages/ui/src/components/ui/dropdown-menu.tsx269
-rw-r--r--packages/ui/src/components/ui/field.tsx238
-rw-r--r--packages/ui/src/components/ui/input.tsx20
-rw-r--r--packages/ui/src/components/ui/label.tsx19
-rw-r--r--packages/ui/src/components/ui/scroll-area.tsx53
-rw-r--r--packages/ui/src/components/ui/select.tsx199
-rw-r--r--packages/ui/src/components/ui/separator.tsx25
-rw-r--r--packages/ui/src/components/ui/sonner.tsx43
-rw-r--r--packages/ui/src/components/ui/spinner.tsx10
-rw-r--r--packages/ui/src/components/ui/switch.tsx32
-rw-r--r--packages/ui/src/components/ui/tabs.tsx80
-rw-r--r--packages/ui/src/components/ui/textarea.tsx18
-rw-r--r--packages/ui/src/components/user-avatar.tsx23
-rw-r--r--packages/ui/src/index.css126
-rw-r--r--packages/ui/src/lib/Counter.svelte10
-rw-r--r--packages/ui/src/lib/DownloadMonitor.svelte201
-rw-r--r--packages/ui/src/lib/GameConsole.svelte304
-rw-r--r--packages/ui/src/lib/effects/ConstellationEffect.ts162
-rw-r--r--packages/ui/src/lib/effects/SaturnEffect.ts303
-rw-r--r--packages/ui/src/lib/modLoaderApi.ts106
-rw-r--r--packages/ui/src/lib/tsrs-utils.ts67
-rw-r--r--packages/ui/src/lib/utils.ts6
-rw-r--r--packages/ui/src/main.ts9
-rw-r--r--packages/ui/src/main.tsx38
-rw-r--r--packages/ui/src/models/auth.ts142
-rw-r--r--packages/ui/src/models/instances.ts135
-rw-r--r--packages/ui/src/models/settings.ts75
-rw-r--r--packages/ui/src/pages/assistant-view.tsx.bk485
-rw-r--r--packages/ui/src/pages/home-view.tsx174
-rw-r--r--packages/ui/src/pages/index.tsx76
-rw-r--r--packages/ui/src/pages/instances-view.tsx315
-rw-r--r--packages/ui/src/pages/settings-view.tsx.bk1158
-rw-r--r--packages/ui/src/pages/settings.tsx310
-rw-r--r--packages/ui/src/pages/versions-view.tsx.bk662
-rw-r--r--packages/ui/src/stores/assistant-store.ts201
-rw-r--r--packages/ui/src/stores/assistant.svelte.ts166
-rw-r--r--packages/ui/src/stores/auth-store.ts296
-rw-r--r--packages/ui/src/stores/auth.svelte.ts192
-rw-r--r--packages/ui/src/stores/game-store.ts101
-rw-r--r--packages/ui/src/stores/game.svelte.ts78
-rw-r--r--packages/ui/src/stores/instances.svelte.ts109
-rw-r--r--packages/ui/src/stores/logs-store.ts200
-rw-r--r--packages/ui/src/stores/logs.svelte.ts151
-rw-r--r--packages/ui/src/stores/releases-store.ts63
-rw-r--r--packages/ui/src/stores/releases.svelte.ts36
-rw-r--r--packages/ui/src/stores/settings-store.ts568
-rw-r--r--packages/ui/src/stores/settings.svelte.ts570
-rw-r--r--packages/ui/src/stores/ui-store.ts42
-rw-r--r--packages/ui/src/stores/ui.svelte.ts32
-rw-r--r--packages/ui/src/types/bindings/account.ts28
-rw-r--r--packages/ui/src/types/bindings/assistant.ts25
-rw-r--r--packages/ui/src/types/bindings/auth.ts32
-rw-r--r--packages/ui/src/types/bindings/config.ts61
-rw-r--r--packages/ui/src/types/bindings/core.ts47
-rw-r--r--packages/ui/src/types/bindings/downloader.ts73
-rw-r--r--packages/ui/src/types/bindings/fabric.ts74
-rw-r--r--packages/ui/src/types/bindings/forge.ts21
-rw-r--r--packages/ui/src/types/bindings/game-version.ts89
-rw-r--r--packages/ui/src/types/bindings/index.ts12
-rw-r--r--packages/ui/src/types/bindings/instance.ts33
-rw-r--r--packages/ui/src/types/bindings/java/core.ts41
-rw-r--r--packages/ui/src/types/bindings/java/index.ts3
-rw-r--r--packages/ui/src/types/bindings/java/persistence.ts7
-rw-r--r--packages/ui/src/types/bindings/java/providers/adoptium.ts37
-rw-r--r--packages/ui/src/types/bindings/java/providers/index.ts1
-rw-r--r--packages/ui/src/types/bindings/manifest.ts22
-rw-r--r--packages/ui/src/types/index.ts233
-rw-r--r--packages/ui/svelte.config.js8
-rw-r--r--packages/ui/tsconfig.app.json36
-rw-r--r--packages/ui/tsconfig.json11
-rw-r--r--packages/ui/vite.config.ts30
113 files changed, 10293 insertions, 9591 deletions
diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md
deleted file mode 100644
index 4b2d22b..0000000
--- a/packages/ui/CHANGELOG.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Changelog
-
-## v0.2.0-alpha.1
-
-### New Features
-
-- [`e7ac28c`](https://github.com/HydroRoll-Team/DropOut/commit/e7ac28c6b8467a8fca0a3b61ba498e4742d3a718): Prepare for alpha mode pre-release. ([#62](https://github.com/HydroRoll-Team/DropOut/pull/62) by @fu050409)
diff --git a/packages/ui/README.md b/packages/ui/README.md
deleted file mode 100644
index a45e2a0..0000000
--- a/packages/ui/README.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Svelte + TS + Vite
-
-This template should help get you started developing with Svelte and TypeScript in Vite.
-
-## Recommended IDE Setup
-
-[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
-
-## Need an official Svelte framework?
-
-Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
-
-## Technical considerations
-
-**Why use this over SvelteKit?**
-
-- It brings its own routing solution which might not be preferable for some users.
-- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
-
-This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
-
-Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
-
-**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
-
-Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
-
-**Why include `.vscode/extensions.json`?**
-
-Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
-
-**Why enable `allowJs` in the TS template?**
-
-While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
-
-**Why is HMR not preserving my local component state?**
-
-HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
-
-If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
-
-```ts
-// store.ts
-// An extremely simple external store
-import { writable } from "svelte/store";
-export default writable(0);
-```
diff --git a/packages/ui/components.json b/packages/ui/components.json
new file mode 100644
index 0000000..f9d4fcd
--- /dev/null
+++ b/packages/ui/components.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "base-lyra",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/packages/ui/index.html b/packages/ui/index.html
index 4fe68e1..5191e6f 100644
--- a/packages/ui/index.html
+++ b/packages/ui/index.html
@@ -2,12 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
- <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+ <link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>Dropout</title>
+ <title>Dropout Launcher</title>
</head>
<body>
- <div id="app"></div>
- <script type="module" src="/src/main.ts"></script>
+ <div id="root"></div>
+ <script type="module" src="/src/main.tsx"></script>
</body>
</html>
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 943fddc..c78290f 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,41 +1,55 @@
{
"name": "@dropout/ui",
- "version": "0.2.0-alpha.1",
"private": true,
+ "version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
- "build": "vite build",
- "preview": "vite preview",
- "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
- "lint": "oxlint .",
- "lint:fix": "oxlint . --fix",
- "format": "oxfmt . --write"
+ "build": "tsc -b && vite build",
+ "lint": "biome check .",
+ "preview": "vite preview"
},
"dependencies": {
+ "@base-ui/react": "^1.2.0",
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-tabs": "^1.1.13",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-shell": "^2.3.4",
- "lucide-svelte": "^0.562.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "es-toolkit": "^1.44.0",
+ "lucide-react": "^0.562.0",
"marked": "^17.0.1",
- "node-emoji": "^2.2.0",
- "prismjs": "^1.30.0"
+ "next-themes": "^0.4.6",
+ "radix-ui": "^1.4.3",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-router": "^7.12.0",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.4.1",
+ "zod": "^4.3.6",
+ "zustand": "^5.0.10"
},
"devDependencies": {
- "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.18",
- "@tsconfig/svelte": "^5.0.6",
"@types/node": "^24.10.1",
- "@types/prismjs": "^1.26.5",
- "autoprefixer": "^10.4.23",
- "oxfmt": "^0.24.0",
- "oxlint": "^1.39.0",
- "postcss": "^8.5.6",
- "svelte": "^5.46.4",
- "svelte-check": "^4.3.4",
+ "@types/react": "^19.2.5",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.1",
+ "globals": "^16.5.0",
+ "shadcn": "^3.8.5",
"tailwindcss": "^4.1.18",
+ "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
- "vite": "npm:rolldown-vite@7.2.5"
+ "vite": "npm:rolldown-vite@^7"
}
}
diff --git a/packages/ui/pnpm-lock.yaml b/packages/ui/pnpm-lock.yaml
deleted file mode 100644
index 465b682..0000000
--- a/packages/ui/pnpm-lock.yaml
+++ /dev/null
@@ -1,1363 +0,0 @@
-lockfileVersion: '9.0'
-
-settings:
- autoInstallPeers: true
- excludeLinksFromLockfile: false
-
-overrides:
- vite: npm:rolldown-vite@7.2.5
-
-importers:
-
- .:
- dependencies:
- '@tauri-apps/api':
- specifier: ^2.9.1
- version: 2.9.1
- '@tauri-apps/plugin-dialog':
- specifier: ^2.6.0
- version: 2.6.0
- '@tauri-apps/plugin-fs':
- specifier: ^2.4.5
- version: 2.4.5
- '@tauri-apps/plugin-shell':
- specifier: ^2.3.4
- version: 2.3.4
- lucide-svelte:
- specifier: ^0.562.0
- version: 0.562.0(svelte@5.46.4)
- marked:
- specifier: ^17.0.1
- version: 17.0.1
- node-emoji:
- specifier: ^2.2.0
- version: 2.2.0
- prismjs:
- specifier: ^1.30.0
- version: 1.30.0
- devDependencies:
- '@sveltejs/vite-plugin-svelte':
- specifier: ^6.2.1
- version: 6.2.4(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4)
- '@tailwindcss/vite':
- specifier: ^4.1.18
- version: 4.1.18(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))
- '@tsconfig/svelte':
- specifier: ^5.0.6
- version: 5.0.6
- '@types/node':
- specifier: ^24.10.1
- version: 24.10.7
- '@types/prismjs':
- specifier: ^1.26.5
- version: 1.26.5
- autoprefixer:
- specifier: ^10.4.23
- version: 10.4.23(postcss@8.5.6)
- oxfmt:
- specifier: ^0.24.0
- version: 0.24.0
- oxlint:
- specifier: ^1.39.0
- version: 1.39.0
- postcss:
- specifier: ^8.5.6
- version: 8.5.6
- svelte:
- specifier: ^5.46.4
- version: 5.46.4
- svelte-check:
- specifier: ^4.3.4
- version: 4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3)
- tailwindcss:
- specifier: ^4.1.18
- version: 4.1.18
- typescript:
- specifier: ~5.9.3
- version: 5.9.3
- vite:
- specifier: npm:rolldown-vite@7.2.5
- version: rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1)
-
-packages:
-
- '@emnapi/core@1.8.1':
- resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
-
- '@emnapi/runtime@1.8.1':
- resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
-
- '@emnapi/wasi-threads@1.1.0':
- resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
-
- '@jridgewell/gen-mapping@0.3.13':
- resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
-
- '@jridgewell/remapping@2.3.5':
- resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
-
- '@jridgewell/resolve-uri@3.1.2':
- resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
- engines: {node: '>=6.0.0'}
-
- '@jridgewell/sourcemap-codec@1.5.5':
- resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
-
- '@jridgewell/trace-mapping@0.3.31':
- resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
-
- '@napi-rs/wasm-runtime@1.1.1':
- resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
-
- '@oxc-project/runtime@0.97.0':
- resolution: {integrity: sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==}
- engines: {node: ^20.19.0 || >=22.12.0}
-
- '@oxc-project/types@0.97.0':
- resolution: {integrity: sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==}
-
- '@oxfmt/darwin-arm64@0.24.0':
- resolution: {integrity: sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A==}
- cpu: [arm64]
- os: [darwin]
-
- '@oxfmt/darwin-x64@0.24.0':
- resolution: {integrity: sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg==}
- cpu: [x64]
- os: [darwin]
-
- '@oxfmt/linux-arm64-gnu@0.24.0':
- resolution: {integrity: sha512-ItPDOPoQ0wLj/s8osc5ch57uUcA1Wk8r0YdO8vLRpXA3UNg7KPOm1vdbkIZRRiSUphZcuX5ioOEetEK8H7RlTw==}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@oxfmt/linux-arm64-musl@0.24.0':
- resolution: {integrity: sha512-JkQO3WnQjQTJONx8nxdgVBfl6BBFfpp9bKhChYhWeakwJdr7QPOAWJ/v3FGZfr0TbqINwnNR74aVZayDDRyXEA==}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@oxfmt/linux-x64-gnu@0.24.0':
- resolution: {integrity: sha512-N/SXlFO+2kak5gMt0oxApi0WXQDhwA0PShR0UbkY0PwtHjfSiDqJSOumyNqgQVoroKr1GNnoRmUqjZIz6DKIcw==}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@oxfmt/linux-x64-musl@0.24.0':
- resolution: {integrity: sha512-WM0pek5YDCQf50XQ7GLCE9sMBCMPW/NPAEPH/Hx6Qyir37lEsP4rUmSECo/QFNTU6KBc9NnsviAyJruWPpCMXw==}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@oxfmt/win32-arm64@0.24.0':
- resolution: {integrity: sha512-vFCseli1KWtwdHrVlT/jWfZ8jP8oYpnPPEjI23mPLW8K/6GEJmmvy0PZP5NpWUFNTzX0lqie58XnrATJYAe9Xw==}
- cpu: [arm64]
- os: [win32]
-
- '@oxfmt/win32-x64@0.24.0':
- resolution: {integrity: sha512-0tmlNzcyewAnauNeBCq0xmAkmiKzl+H09p0IdHy+QKrTQdtixtf+AOjDAADbRfihkS+heF15Pjc4IyJMdAAJjw==}
- cpu: [x64]
- os: [win32]
-
- '@oxlint/darwin-arm64@1.39.0':
- resolution: {integrity: sha512-lT3hNhIa02xCujI6YGgjmYGg3Ht/X9ag5ipUVETaMpx5Rd4BbTNWUPif1WN1YZHxt3KLCIqaAe7zVhatv83HOQ==}
- cpu: [arm64]
- os: [darwin]
-
- '@oxlint/darwin-x64@1.39.0':
- resolution: {integrity: sha512-UT+rfTWd+Yr7iJeSLd/7nF8X4gTYssKh+n77hxl6Oilp3NnG1CKRHxZDy3o3lIBnwgzJkdyUAiYWO1bTMXQ1lA==}
- cpu: [x64]
- os: [darwin]
-
- '@oxlint/linux-arm64-gnu@1.39.0':
- resolution: {integrity: sha512-qocBkvS2V6rH0t9AT3DfQunMnj3xkM7srs5/Ycj2j5ZqMoaWd/FxHNVJDFP++35roKSvsRJoS0mtA8/77jqm6Q==}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@oxlint/linux-arm64-musl@1.39.0':
- resolution: {integrity: sha512-arZzAc1PPcz9epvGBBCMHICeyQloKtHX3eoOe62B3Dskn7gf6Q14wnDHr1r9Vp4vtcBATNq6HlKV14smdlC/qA==}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@oxlint/linux-x64-gnu@1.39.0':
- resolution: {integrity: sha512-ZVt5qsECpuNprdWxAPpDBwoixr1VTcZ4qAEQA2l/wmFyVPDYFD3oBY/SWACNnWBddMrswjTg9O8ALxYWoEpmXw==}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@oxlint/linux-x64-musl@1.39.0':
- resolution: {integrity: sha512-pB0hlGyKPbxr9NMIV783lD6cWL3MpaqnZRM9MWni4yBdHPTKyFNYdg5hGD0Bwg+UP4S2rOevq/+OO9x9Bi7E6g==}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@oxlint/win32-arm64@1.39.0':
- resolution: {integrity: sha512-Gg2SFaJohI9+tIQVKXlPw3FsPQFi/eCSWiCgwPtPn5uzQxHRTeQEZKuluz1fuzR5U70TXubb2liZi4Dgl8LJQA==}
- cpu: [arm64]
- os: [win32]
-
- '@oxlint/win32-x64@1.39.0':
- resolution: {integrity: sha512-sbi25lfj74hH+6qQtb7s1wEvd1j8OQbTaH8v3xTcDjrwm579Cyh0HBv1YSZ2+gsnVwfVDiCTL1D0JsNqYXszVA==}
- cpu: [x64]
- os: [win32]
-
- '@rolldown/binding-android-arm64@1.0.0-beta.50':
- resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [android]
-
- '@rolldown/binding-darwin-arm64@1.0.0-beta.50':
- resolution: {integrity: sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [darwin]
-
- '@rolldown/binding-darwin-x64@1.0.0-beta.50':
- resolution: {integrity: sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [darwin]
-
- '@rolldown/binding-freebsd-x64@1.0.0-beta.50':
- resolution: {integrity: sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [freebsd]
-
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50':
- resolution: {integrity: sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm]
- os: [linux]
-
- '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50':
- resolution: {integrity: sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50':
- resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50':
- resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@rolldown/binding-linux-x64-musl@1.0.0-beta.50':
- resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@rolldown/binding-openharmony-arm64@1.0.0-beta.50':
- resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [openharmony]
-
- '@rolldown/binding-wasm32-wasi@1.0.0-beta.50':
- resolution: {integrity: sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==}
- engines: {node: '>=14.0.0'}
- cpu: [wasm32]
-
- '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50':
- resolution: {integrity: sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [win32]
-
- '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50':
- resolution: {integrity: sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [ia32]
- os: [win32]
-
- '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50':
- resolution: {integrity: sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [win32]
-
- '@rolldown/pluginutils@1.0.0-beta.50':
- resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==}
-
- '@sindresorhus/is@4.6.0':
- resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
- engines: {node: '>=10'}
-
- '@sveltejs/acorn-typescript@1.0.8':
- resolution: {integrity: sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==}
- peerDependencies:
- acorn: ^8.9.0
-
- '@sveltejs/vite-plugin-svelte-inspector@5.0.2':
- resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==}
- engines: {node: ^20.19 || ^22.12 || >=24}
- peerDependencies:
- '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0
- svelte: ^5.0.0
- vite: ^6.3.0 || ^7.0.0
-
- '@sveltejs/vite-plugin-svelte@6.2.4':
- resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==}
- engines: {node: ^20.19 || ^22.12 || >=24}
- peerDependencies:
- svelte: ^5.0.0
- vite: ^6.3.0 || ^7.0.0
-
- '@tailwindcss/node@4.1.18':
- resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
-
- '@tailwindcss/oxide-android-arm64@4.1.18':
- resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [android]
-
- '@tailwindcss/oxide-darwin-arm64@4.1.18':
- resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [darwin]
-
- '@tailwindcss/oxide-darwin-x64@4.1.18':
- resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [darwin]
-
- '@tailwindcss/oxide-freebsd-x64@4.1.18':
- resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [freebsd]
-
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
- resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==}
- engines: {node: '>= 10'}
- cpu: [arm]
- os: [linux]
-
- '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
- resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
- resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
- resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@tailwindcss/oxide-linux-x64-musl@4.1.18':
- resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@tailwindcss/oxide-wasm32-wasi@4.1.18':
- resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
- engines: {node: '>=14.0.0'}
- cpu: [wasm32]
- bundledDependencies:
- - '@napi-rs/wasm-runtime'
- - '@emnapi/core'
- - '@emnapi/runtime'
- - '@tybys/wasm-util'
- - '@emnapi/wasi-threads'
- - tslib
-
- '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
- resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [win32]
-
- '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
- resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [win32]
-
- '@tailwindcss/oxide@4.1.18':
- resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==}
- engines: {node: '>= 10'}
-
- '@tailwindcss/vite@4.1.18':
- resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==}
- peerDependencies:
- vite: ^5.2.0 || ^6 || ^7
-
- '@tauri-apps/api@2.9.1':
- resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==}
-
- '@tauri-apps/plugin-dialog@2.6.0':
- resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
-
- '@tauri-apps/plugin-fs@2.4.5':
- resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==}
-
- '@tauri-apps/plugin-shell@2.3.4':
- resolution: {integrity: sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==}
-
- '@tsconfig/svelte@5.0.6':
- resolution: {integrity: sha512-yGxYL0I9eETH1/DR9qVJey4DAsCdeau4a9wYPKuXfEhm8lFO8wg+LLYJjIpAm6Fw7HSlhepPhYPDop75485yWQ==}
-
- '@tybys/wasm-util@0.10.1':
- resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
-
- '@types/estree@1.0.8':
- resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
-
- '@types/node@24.10.7':
- resolution: {integrity: sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ==}
-
- '@types/prismjs@1.26.5':
- resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
-
- acorn@8.15.0:
- resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
- engines: {node: '>=0.4.0'}
- hasBin: true
-
- aria-query@5.3.2:
- resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
- engines: {node: '>= 0.4'}
-
- autoprefixer@10.4.23:
- resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==}
- engines: {node: ^10 || ^12 || >=14}
- hasBin: true
- peerDependencies:
- postcss: ^8.1.0
-
- axobject-query@4.1.0:
- resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
- engines: {node: '>= 0.4'}
-
- baseline-browser-mapping@2.9.14:
- resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
- hasBin: true
-
- browserslist@4.28.1:
- resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
- engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
- hasBin: true
-
- caniuse-lite@1.0.30001764:
- resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==}
-
- char-regex@1.0.2:
- resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
- engines: {node: '>=10'}
-
- chokidar@4.0.3:
- resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
- engines: {node: '>= 14.16.0'}
-
- clsx@2.1.1:
- resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
- engines: {node: '>=6'}
-
- deepmerge@4.3.1:
- resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
- engines: {node: '>=0.10.0'}
-
- detect-libc@2.1.2:
- resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
- engines: {node: '>=8'}
-
- devalue@5.6.2:
- resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==}
-
- electron-to-chromium@1.5.267:
- resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
-
- emojilib@2.4.0:
- resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==}
-
- enhanced-resolve@5.18.4:
- resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
- engines: {node: '>=10.13.0'}
-
- escalade@3.2.0:
- resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
- engines: {node: '>=6'}
-
- esm-env@1.2.2:
- resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
-
- esrap@2.2.1:
- resolution: {integrity: sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==}
-
- fdir@6.5.0:
- resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
- engines: {node: '>=12.0.0'}
- peerDependencies:
- picomatch: ^3 || ^4
- peerDependenciesMeta:
- picomatch:
- optional: true
-
- fraction.js@5.3.4:
- resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
-
- fsevents@2.3.3:
- resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
- engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
- os: [darwin]
-
- graceful-fs@4.2.11:
- resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
-
- is-reference@3.0.3:
- resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
-
- jiti@2.6.1:
- resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
- hasBin: true
-
- lightningcss-android-arm64@1.30.2:
- resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [android]
-
- lightningcss-darwin-arm64@1.30.2:
- resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [darwin]
-
- lightningcss-darwin-x64@1.30.2:
- resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [darwin]
-
- lightningcss-freebsd-x64@1.30.2:
- resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [freebsd]
-
- lightningcss-linux-arm-gnueabihf@1.30.2:
- resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm]
- os: [linux]
-
- lightningcss-linux-arm64-gnu@1.30.2:
- resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- lightningcss-linux-arm64-musl@1.30.2:
- resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- lightningcss-linux-x64-gnu@1.30.2:
- resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- lightningcss-linux-x64-musl@1.30.2:
- resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- lightningcss-win32-arm64-msvc@1.30.2:
- resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [win32]
-
- lightningcss-win32-x64-msvc@1.30.2:
- resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [win32]
-
- lightningcss@1.30.2:
- resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
- engines: {node: '>= 12.0.0'}
-
- locate-character@3.0.0:
- resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
-
- lucide-svelte@0.562.0:
- resolution: {integrity: sha512-kSJDH/55lf0mun/o4nqWBXOcq0fWYzPeIjbTD97ywoeumAB9kWxtM06gC7oynqjtK3XhAljWSz5RafIzPEYIQA==}
- peerDependencies:
- svelte: ^3 || ^4 || ^5.0.0-next.42
-
- magic-string@0.30.21:
- resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
-
- marked@17.0.1:
- resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
- engines: {node: '>= 20'}
- hasBin: true
-
- mri@1.2.0:
- resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
- engines: {node: '>=4'}
-
- nanoid@3.3.11:
- resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
- hasBin: true
-
- node-emoji@2.2.0:
- resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==}
- engines: {node: '>=18'}
-
- node-releases@2.0.27:
- resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
-
- obug@2.1.1:
- resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
-
- oxfmt@0.24.0:
- resolution: {integrity: sha512-UjeM3Peez8Tl7IJ9s5UwAoZSiDRMww7BEc21gDYxLq3S3/KqJnM3mjNxsoSHgmBvSeX6RBhoVc2MfC/+96RdSw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
-
- oxlint@1.39.0:
- resolution: {integrity: sha512-wSiLr0wjG+KTU6c1LpVoQk7JZ7l8HCKlAkVDVTJKWmCGazsNxexxnOXl7dsar92mQcRnzko5g077ggP3RINSjA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
- peerDependencies:
- oxlint-tsgolint: '>=0.10.0'
- peerDependenciesMeta:
- oxlint-tsgolint:
- optional: true
-
- picocolors@1.1.1:
- resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
-
- picomatch@4.0.3:
- resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
- engines: {node: '>=12'}
-
- postcss-value-parser@4.2.0:
- resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
-
- postcss@8.5.6:
- resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
- engines: {node: ^10 || ^12 || >=14}
-
- prismjs@1.30.0:
- resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
- engines: {node: '>=6'}
-
- readdirp@4.1.2:
- resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
- engines: {node: '>= 14.18.0'}
-
- rolldown-vite@7.2.5:
- resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
- peerDependencies:
- '@types/node': ^20.19.0 || >=22.12.0
- esbuild: ^0.25.0
- jiti: '>=1.21.0'
- less: ^4.0.0
- sass: ^1.70.0
- sass-embedded: ^1.70.0
- stylus: '>=0.54.8'
- sugarss: ^5.0.0
- terser: ^5.16.0
- tsx: ^4.8.1
- yaml: ^2.4.2
- peerDependenciesMeta:
- '@types/node':
- optional: true
- esbuild:
- optional: true
- jiti:
- optional: true
- less:
- optional: true
- sass:
- optional: true
- sass-embedded:
- optional: true
- stylus:
- optional: true
- sugarss:
- optional: true
- terser:
- optional: true
- tsx:
- optional: true
- yaml:
- optional: true
-
- rolldown@1.0.0-beta.50:
- resolution: {integrity: sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
-
- sade@1.8.1:
- resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
- engines: {node: '>=6'}
-
- skin-tone@2.0.0:
- resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==}
- engines: {node: '>=8'}
-
- source-map-js@1.2.1:
- resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
- engines: {node: '>=0.10.0'}
-
- svelte-check@4.3.5:
- resolution: {integrity: sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==}
- engines: {node: '>= 18.0.0'}
- hasBin: true
- peerDependencies:
- svelte: ^4.0.0 || ^5.0.0-next.0
- typescript: '>=5.0.0'
-
- svelte@5.46.4:
- resolution: {integrity: sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==}
- engines: {node: '>=18'}
-
- tailwindcss@4.1.18:
- resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
-
- tapable@2.3.0:
- resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
- engines: {node: '>=6'}
-
- tinyglobby@0.2.15:
- resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
- engines: {node: '>=12.0.0'}
-
- tinypool@2.0.0:
- resolution: {integrity: sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg==}
- engines: {node: ^20.0.0 || >=22.0.0}
-
- tslib@2.8.1:
- resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
-
- typescript@5.9.3:
- resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
- engines: {node: '>=14.17'}
- hasBin: true
-
- undici-types@7.16.0:
- resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
-
- unicode-emoji-modifier-base@1.0.0:
- resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==}
- engines: {node: '>=4'}
-
- update-browserslist-db@1.2.3:
- resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
- hasBin: true
- peerDependencies:
- browserslist: '>= 4.21.0'
-
- vitefu@1.1.1:
- resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==}
- peerDependencies:
- vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0
- peerDependenciesMeta:
- vite:
- optional: true
-
- zimmerframe@1.1.4:
- resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
-
-snapshots:
-
- '@emnapi/core@1.8.1':
- dependencies:
- '@emnapi/wasi-threads': 1.1.0
- tslib: 2.8.1
- optional: true
-
- '@emnapi/runtime@1.8.1':
- dependencies:
- tslib: 2.8.1
- optional: true
-
- '@emnapi/wasi-threads@1.1.0':
- dependencies:
- tslib: 2.8.1
- optional: true
-
- '@jridgewell/gen-mapping@0.3.13':
- dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
- '@jridgewell/trace-mapping': 0.3.31
-
- '@jridgewell/remapping@2.3.5':
- dependencies:
- '@jridgewell/gen-mapping': 0.3.13
- '@jridgewell/trace-mapping': 0.3.31
-
- '@jridgewell/resolve-uri@3.1.2': {}
-
- '@jridgewell/sourcemap-codec@1.5.5': {}
-
- '@jridgewell/trace-mapping@0.3.31':
- dependencies:
- '@jridgewell/resolve-uri': 3.1.2
- '@jridgewell/sourcemap-codec': 1.5.5
-
- '@napi-rs/wasm-runtime@1.1.1':
- dependencies:
- '@emnapi/core': 1.8.1
- '@emnapi/runtime': 1.8.1
- '@tybys/wasm-util': 0.10.1
- optional: true
-
- '@oxc-project/runtime@0.97.0': {}
-
- '@oxc-project/types@0.97.0': {}
-
- '@oxfmt/darwin-arm64@0.24.0':
- optional: true
-
- '@oxfmt/darwin-x64@0.24.0':
- optional: true
-
- '@oxfmt/linux-arm64-gnu@0.24.0':
- optional: true
-
- '@oxfmt/linux-arm64-musl@0.24.0':
- optional: true
-
- '@oxfmt/linux-x64-gnu@0.24.0':
- optional: true
-
- '@oxfmt/linux-x64-musl@0.24.0':
- optional: true
-
- '@oxfmt/win32-arm64@0.24.0':
- optional: true
-
- '@oxfmt/win32-x64@0.24.0':
- optional: true
-
- '@oxlint/darwin-arm64@1.39.0':
- optional: true
-
- '@oxlint/darwin-x64@1.39.0':
- optional: true
-
- '@oxlint/linux-arm64-gnu@1.39.0':
- optional: true
-
- '@oxlint/linux-arm64-musl@1.39.0':
- optional: true
-
- '@oxlint/linux-x64-gnu@1.39.0':
- optional: true
-
- '@oxlint/linux-x64-musl@1.39.0':
- optional: true
-
- '@oxlint/win32-arm64@1.39.0':
- optional: true
-
- '@oxlint/win32-x64@1.39.0':
- optional: true
-
- '@rolldown/binding-android-arm64@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-darwin-arm64@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-darwin-x64@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-freebsd-x64@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-linux-x64-musl@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-openharmony-arm64@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-wasm32-wasi@1.0.0-beta.50':
- dependencies:
- '@napi-rs/wasm-runtime': 1.1.1
- optional: true
-
- '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50':
- optional: true
-
- '@rolldown/pluginutils@1.0.0-beta.50': {}
-
- '@sindresorhus/is@4.6.0': {}
-
- '@sveltejs/acorn-typescript@1.0.8(acorn@8.15.0)':
- dependencies:
- acorn: 8.15.0
-
- '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4))(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4)':
- dependencies:
- '@sveltejs/vite-plugin-svelte': 6.2.4(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4)
- obug: 2.1.1
- svelte: 5.46.4
- vite: rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1)
-
- '@sveltejs/vite-plugin-svelte@6.2.4(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4)':
- dependencies:
- '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4))(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4)
- deepmerge: 4.3.1
- magic-string: 0.30.21
- obug: 2.1.1
- svelte: 5.46.4
- vite: rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1)
- vitefu: 1.1.1(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))
-
- '@tailwindcss/node@4.1.18':
- dependencies:
- '@jridgewell/remapping': 2.3.5
- enhanced-resolve: 5.18.4
- jiti: 2.6.1
- lightningcss: 1.30.2
- magic-string: 0.30.21
- source-map-js: 1.2.1
- tailwindcss: 4.1.18
-
- '@tailwindcss/oxide-android-arm64@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-darwin-arm64@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-darwin-x64@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-freebsd-x64@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-linux-x64-musl@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-wasm32-wasi@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
- optional: true
-
- '@tailwindcss/oxide@4.1.18':
- optionalDependencies:
- '@tailwindcss/oxide-android-arm64': 4.1.18
- '@tailwindcss/oxide-darwin-arm64': 4.1.18
- '@tailwindcss/oxide-darwin-x64': 4.1.18
- '@tailwindcss/oxide-freebsd-x64': 4.1.18
- '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18
- '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18
- '@tailwindcss/oxide-linux-arm64-musl': 4.1.18
- '@tailwindcss/oxide-linux-x64-gnu': 4.1.18
- '@tailwindcss/oxide-linux-x64-musl': 4.1.18
- '@tailwindcss/oxide-wasm32-wasi': 4.1.18
- '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
- '@tailwindcss/oxide-win32-x64-msvc': 4.1.18
-
- '@tailwindcss/vite@4.1.18(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))':
- dependencies:
- '@tailwindcss/node': 4.1.18
- '@tailwindcss/oxide': 4.1.18
- tailwindcss: 4.1.18
- vite: rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1)
-
- '@tauri-apps/api@2.9.1': {}
-
- '@tauri-apps/plugin-dialog@2.6.0':
- dependencies:
- '@tauri-apps/api': 2.9.1
-
- '@tauri-apps/plugin-fs@2.4.5':
- dependencies:
- '@tauri-apps/api': 2.9.1
-
- '@tauri-apps/plugin-shell@2.3.4':
- dependencies:
- '@tauri-apps/api': 2.9.1
-
- '@tsconfig/svelte@5.0.6': {}
-
- '@tybys/wasm-util@0.10.1':
- dependencies:
- tslib: 2.8.1
- optional: true
-
- '@types/estree@1.0.8': {}
-
- '@types/node@24.10.7':
- dependencies:
- undici-types: 7.16.0
-
- '@types/prismjs@1.26.5': {}
-
- acorn@8.15.0: {}
-
- aria-query@5.3.2: {}
-
- autoprefixer@10.4.23(postcss@8.5.6):
- dependencies:
- browserslist: 4.28.1
- caniuse-lite: 1.0.30001764
- fraction.js: 5.3.4
- picocolors: 1.1.1
- postcss: 8.5.6
- postcss-value-parser: 4.2.0
-
- axobject-query@4.1.0: {}
-
- baseline-browser-mapping@2.9.14: {}
-
- browserslist@4.28.1:
- dependencies:
- baseline-browser-mapping: 2.9.14
- caniuse-lite: 1.0.30001764
- electron-to-chromium: 1.5.267
- node-releases: 2.0.27
- update-browserslist-db: 1.2.3(browserslist@4.28.1)
-
- caniuse-lite@1.0.30001764: {}
-
- char-regex@1.0.2: {}
-
- chokidar@4.0.3:
- dependencies:
- readdirp: 4.1.2
-
- clsx@2.1.1: {}
-
- deepmerge@4.3.1: {}
-
- detect-libc@2.1.2: {}
-
- devalue@5.6.2: {}
-
- electron-to-chromium@1.5.267: {}
-
- emojilib@2.4.0: {}
-
- enhanced-resolve@5.18.4:
- dependencies:
- graceful-fs: 4.2.11
- tapable: 2.3.0
-
- escalade@3.2.0: {}
-
- esm-env@1.2.2: {}
-
- esrap@2.2.1:
- dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
-
- fdir@6.5.0(picomatch@4.0.3):
- optionalDependencies:
- picomatch: 4.0.3
-
- fraction.js@5.3.4: {}
-
- fsevents@2.3.3:
- optional: true
-
- graceful-fs@4.2.11: {}
-
- is-reference@3.0.3:
- dependencies:
- '@types/estree': 1.0.8
-
- jiti@2.6.1: {}
-
- lightningcss-android-arm64@1.30.2:
- optional: true
-
- lightningcss-darwin-arm64@1.30.2:
- optional: true
-
- lightningcss-darwin-x64@1.30.2:
- optional: true
-
- lightningcss-freebsd-x64@1.30.2:
- optional: true
-
- lightningcss-linux-arm-gnueabihf@1.30.2:
- optional: true
-
- lightningcss-linux-arm64-gnu@1.30.2:
- optional: true
-
- lightningcss-linux-arm64-musl@1.30.2:
- optional: true
-
- lightningcss-linux-x64-gnu@1.30.2:
- optional: true
-
- lightningcss-linux-x64-musl@1.30.2:
- optional: true
-
- lightningcss-win32-arm64-msvc@1.30.2:
- optional: true
-
- lightningcss-win32-x64-msvc@1.30.2:
- optional: true
-
- lightningcss@1.30.2:
- dependencies:
- detect-libc: 2.1.2
- optionalDependencies:
- lightningcss-android-arm64: 1.30.2
- lightningcss-darwin-arm64: 1.30.2
- lightningcss-darwin-x64: 1.30.2
- lightningcss-freebsd-x64: 1.30.2
- lightningcss-linux-arm-gnueabihf: 1.30.2
- lightningcss-linux-arm64-gnu: 1.30.2
- lightningcss-linux-arm64-musl: 1.30.2
- lightningcss-linux-x64-gnu: 1.30.2
- lightningcss-linux-x64-musl: 1.30.2
- lightningcss-win32-arm64-msvc: 1.30.2
- lightningcss-win32-x64-msvc: 1.30.2
-
- locate-character@3.0.0: {}
-
- lucide-svelte@0.562.0(svelte@5.46.4):
- dependencies:
- svelte: 5.46.4
-
- magic-string@0.30.21:
- dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
-
- marked@17.0.1: {}
-
- mri@1.2.0: {}
-
- nanoid@3.3.11: {}
-
- node-emoji@2.2.0:
- dependencies:
- '@sindresorhus/is': 4.6.0
- char-regex: 1.0.2
- emojilib: 2.4.0
- skin-tone: 2.0.0
-
- node-releases@2.0.27: {}
-
- obug@2.1.1: {}
-
- oxfmt@0.24.0:
- dependencies:
- tinypool: 2.0.0
- optionalDependencies:
- '@oxfmt/darwin-arm64': 0.24.0
- '@oxfmt/darwin-x64': 0.24.0
- '@oxfmt/linux-arm64-gnu': 0.24.0
- '@oxfmt/linux-arm64-musl': 0.24.0
- '@oxfmt/linux-x64-gnu': 0.24.0
- '@oxfmt/linux-x64-musl': 0.24.0
- '@oxfmt/win32-arm64': 0.24.0
- '@oxfmt/win32-x64': 0.24.0
-
- oxlint@1.39.0:
- optionalDependencies:
- '@oxlint/darwin-arm64': 1.39.0
- '@oxlint/darwin-x64': 1.39.0
- '@oxlint/linux-arm64-gnu': 1.39.0
- '@oxlint/linux-arm64-musl': 1.39.0
- '@oxlint/linux-x64-gnu': 1.39.0
- '@oxlint/linux-x64-musl': 1.39.0
- '@oxlint/win32-arm64': 1.39.0
- '@oxlint/win32-x64': 1.39.0
-
- picocolors@1.1.1: {}
-
- picomatch@4.0.3: {}
-
- postcss-value-parser@4.2.0: {}
-
- postcss@8.5.6:
- dependencies:
- nanoid: 3.3.11
- picocolors: 1.1.1
- source-map-js: 1.2.1
-
- prismjs@1.30.0: {}
-
- readdirp@4.1.2: {}
-
- rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1):
- dependencies:
- '@oxc-project/runtime': 0.97.0
- fdir: 6.5.0(picomatch@4.0.3)
- lightningcss: 1.30.2
- picomatch: 4.0.3
- postcss: 8.5.6
- rolldown: 1.0.0-beta.50
- tinyglobby: 0.2.15
- optionalDependencies:
- '@types/node': 24.10.7
- fsevents: 2.3.3
- jiti: 2.6.1
-
- rolldown@1.0.0-beta.50:
- dependencies:
- '@oxc-project/types': 0.97.0
- '@rolldown/pluginutils': 1.0.0-beta.50
- optionalDependencies:
- '@rolldown/binding-android-arm64': 1.0.0-beta.50
- '@rolldown/binding-darwin-arm64': 1.0.0-beta.50
- '@rolldown/binding-darwin-x64': 1.0.0-beta.50
- '@rolldown/binding-freebsd-x64': 1.0.0-beta.50
- '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.50
- '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.50
- '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.50
- '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.50
- '@rolldown/binding-linux-x64-musl': 1.0.0-beta.50
- '@rolldown/binding-openharmony-arm64': 1.0.0-beta.50
- '@rolldown/binding-wasm32-wasi': 1.0.0-beta.50
- '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.50
- '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.50
- '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.50
-
- sade@1.8.1:
- dependencies:
- mri: 1.2.0
-
- skin-tone@2.0.0:
- dependencies:
- unicode-emoji-modifier-base: 1.0.0
-
- source-map-js@1.2.1: {}
-
- svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3):
- dependencies:
- '@jridgewell/trace-mapping': 0.3.31
- chokidar: 4.0.3
- fdir: 6.5.0(picomatch@4.0.3)
- picocolors: 1.1.1
- sade: 1.8.1
- svelte: 5.46.4
- typescript: 5.9.3
- transitivePeerDependencies:
- - picomatch
-
- svelte@5.46.4:
- dependencies:
- '@jridgewell/remapping': 2.3.5
- '@jridgewell/sourcemap-codec': 1.5.5
- '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0)
- '@types/estree': 1.0.8
- acorn: 8.15.0
- aria-query: 5.3.2
- axobject-query: 4.1.0
- clsx: 2.1.1
- devalue: 5.6.2
- esm-env: 1.2.2
- esrap: 2.2.1
- is-reference: 3.0.3
- locate-character: 3.0.0
- magic-string: 0.30.21
- zimmerframe: 1.1.4
-
- tailwindcss@4.1.18: {}
-
- tapable@2.3.0: {}
-
- tinyglobby@0.2.15:
- dependencies:
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
-
- tinypool@2.0.0: {}
-
- tslib@2.8.1:
- optional: true
-
- typescript@5.9.3: {}
-
- undici-types@7.16.0: {}
-
- unicode-emoji-modifier-base@1.0.0: {}
-
- update-browserslist-db@1.2.3(browserslist@4.28.1):
- dependencies:
- browserslist: 4.28.1
- escalade: 3.2.0
- picocolors: 1.1.1
-
- vitefu@1.1.1(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1)):
- optionalDependencies:
- vite: rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1)
-
- zimmerframe@1.1.4: {}
diff --git a/packages/ui/public/icon.svg b/packages/ui/public/icon.svg
new file mode 100644
index 0000000..0baf00f
--- /dev/null
+++ b/packages/ui/public/icon.svg
@@ -0,0 +1,50 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
+ <!-- Background -->
+ <rect width="100%" height="100%" fill="#23272a"/>
+
+ <!-- Grid Pattern -->
+ <defs>
+ <pattern id="smallGrid" width="40" height="40" patternUnits="userSpaceOnUse">
+ <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#2c2f33" stroke-width="2"/>
+ </pattern>
+ <!-- Glow filter for active connections -->
+ <filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
+ <feGaussianBlur stdDeviation="3" result="blur" />
+ <feComposite in="SourceGraphic" in2="blur" operator="over" />
+ </filter>
+ </defs>
+ <rect width="100%" height="100%" fill="url(#smallGrid)" />
+
+ <!-- Neural Network Connections (Lines) -->
+ <!-- Only lines between ACTIVE nodes are drawn normally -->
+
+ <!-- Input (Left) to Hidden (Middle Active) -->
+ <path d="M 100 128 L 256 256" stroke="#43b581" stroke-width="8" stroke-linecap="round" opacity="0.8"/> <!-- Top to Center -->
+ <path d="M 100 256 L 256 256" stroke="#43b581" stroke-width="8" stroke-linecap="round" opacity="1.0" filter="url(#glow)"/> <!-- Mid to Center (Strongest) -->
+ <path d="M 100 384 L 256 256" stroke="#43b581" stroke-width="8" stroke-linecap="round" opacity="0.8"/> <!-- Bot to Center -->
+
+ <!-- Hidden (Middle Active) to Output (Right) -->
+ <path d="M 256 256 L 412 256" stroke="#43b581" stroke-width="8" stroke-linecap="round" opacity="1.0" filter="url(#glow)"/>
+
+ <!-- Disconnected "Ghost" Lines (Optional: faint traces, or just omit to emphasize dropout) -->
+ <!-- Let's omit them to keep it clean and high-contrast, representing true dropout -->
+
+ <!-- Nodes -->
+
+ <!-- Layer 1: Input (All Active) - x=100 -->
+ <circle cx="100" cy="128" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
+ <circle cx="100" cy="256" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
+ <circle cx="100" cy="384" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
+
+ <!-- Layer 2: Hidden (Dropout Layer) - x=256 -->
+ <!-- Node 1: DROPPED (Ghost) -->
+ <circle cx="256" cy="128" r="28" fill="none" stroke="#4f545c" stroke-width="4" stroke-dasharray="8,6"/>
+ <!-- Node 2: ACTIVE -->
+ <circle cx="256" cy="256" r="32" fill="#43b581" stroke="#ffffff" stroke-width="4"/>
+ <!-- Node 3: DROPPED (Ghost) -->
+ <circle cx="256" cy="384" r="28" fill="none" stroke="#4f545c" stroke-width="4" stroke-dasharray="8,6"/>
+
+ <!-- Layer 3: Output - x=412 -->
+ <circle cx="412" cy="256" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
+
+</svg>
diff --git a/packages/ui/public/vite.svg b/packages/ui/public/vite.svg
deleted file mode 100644
index ee9fada..0000000
--- a/packages/ui/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
diff --git a/packages/ui/src/App.svelte b/packages/ui/src/App.svelte
deleted file mode 100644
index f73e0a2..0000000
--- a/packages/ui/src/App.svelte
+++ /dev/null
@@ -1,217 +0,0 @@
-<script lang="ts">
- import { getVersion } from "@tauri-apps/api/app";
- // import { convertFileSrc } from "@tauri-apps/api/core"; // Removed duplicate, handled by import below or inline
- import { onDestroy, onMount } from "svelte";
- import DownloadMonitor from "./lib/DownloadMonitor.svelte";
- import GameConsole from "./lib/GameConsole.svelte";
-// Components
- import BottomBar from "./components/BottomBar.svelte";
- import HomeView from "./components/HomeView.svelte";
- import LoginModal from "./components/LoginModal.svelte";
- import ParticleBackground from "./components/ParticleBackground.svelte";
- import SettingsView from "./components/SettingsView.svelte";
- import AssistantView from "./components/AssistantView.svelte";
- import InstancesView from "./components/InstancesView.svelte";
- import Sidebar from "./components/Sidebar.svelte";
- import StatusToast from "./components/StatusToast.svelte";
- import VersionsView from "./components/VersionsView.svelte";
-// Stores
- import { authState } from "./stores/auth.svelte";
- import { gameState } from "./stores/game.svelte";
- import { instancesState } from "./stores/instances.svelte";
- import { settingsState } from "./stores/settings.svelte";
- import { uiState } from "./stores/ui.svelte";
- import { logsState } from "./stores/logs.svelte";
- import { convertFileSrc } from "@tauri-apps/api/core";
-
- let mouseX = $state(0);
- let mouseY = $state(0);
-
- function handleMouseMove(e: MouseEvent) {
- mouseX = (e.clientX / window.innerWidth) * 2 - 1;
- mouseY = (e.clientY / window.innerHeight) * 2 - 1;
- }
-
- onMount(async () => {
- // ENFORCE DARK MODE: Always add 'dark' class and attribute
- document.documentElement.classList.add('dark');
- document.documentElement.setAttribute('data-theme', 'dark');
- document.documentElement.classList.remove('light');
-
- authState.checkAccount();
- await settingsState.loadSettings();
- logsState.init();
- await settingsState.detectJava();
- await instancesState.loadInstances();
- gameState.loadVersions();
- getVersion().then((v) => (uiState.appVersion = v));
- window.addEventListener("mousemove", handleMouseMove);
- });
-
- // Refresh versions when active instance changes
- $effect(() => {
- if (instancesState.activeInstanceId) {
- gameState.loadVersions();
- } else {
- gameState.versions = [];
- }
- });
-
- onDestroy(() => {
- if (typeof window !== 'undefined')
- window.removeEventListener("mousemove", handleMouseMove);
- });
-</script>
-
-<div
- class="relative h-screen w-screen overflow-hidden dark:text-white text-gray-900 font-sans selection:bg-indigo-500/30"
->
- <!-- Modern Animated Background -->
- <div class="absolute inset-0 z-0 bg-[#09090b] dark:bg-[#09090b] bg-gray-100 overflow-hidden">
- {#if settingsState.settings.custom_background_path}
- <img
- src={convertFileSrc(settingsState.settings.custom_background_path)}
- alt="Background"
- class="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear hover:scale-105"
- onerror={(e) => console.error("Failed to load main background:", e)}
- />
- <!-- Dimming Overlay for readability -->
- <div class="absolute inset-0 bg-black/50 "></div>
- {:else if settingsState.settings.enable_visual_effects}
- <!-- Original Gradient (Dark Only / or Adjusted for Light) -->
- {#if settingsState.settings.theme === 'dark'}
- <div
- class="absolute inset-0 opacity-60 bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950"
- ></div>
- {:else}
- <!-- Light Mode Gradient -->
- <div
- class="absolute inset-0 opacity-100 bg-gradient-to-br from-emerald-100 via-gray-100 to-indigo-100"
- ></div>
- {/if}
-
- {#if uiState.currentView === "home"}
- <ParticleBackground />
- {/if}
-
- <div
- class="absolute inset-0 bg-gradient-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50 from-gray-100 to-transparent"
- ></div>
- {/if}
-
- <!-- Subtle Grid Overlay -->
- <div class="absolute inset-0 z-0 opacity-10 dark:opacity-10 opacity-30 pointer-events-none"
- style="background-image: linear-gradient({settingsState.settings.theme === 'dark' ? '#ffffff' : '#000000'} 1px, transparent 1px), linear-gradient(90deg, {settingsState.settings.theme === 'dark' ? '#ffffff' : '#000000'} 1px, transparent 1px); background-size: 40px 40px; mask-image: radial-gradient(circle at 50% 50%, black 30%, transparent 70%);">
- </div>
- </div>
-
- <!-- Content Wrapper -->
- <div class="relative z-10 flex h-full p-4 gap-4 text-gray-900 dark:text-white">
- <!-- Floating Sidebar -->
- <Sidebar />
-
- <!-- Main Content Area - Transparent & Flat -->
- <main class="flex-1 flex flex-col relative min-w-0 overflow-hidden transition-all duration-300">
-
- <!-- Window Drag Region -->
- <div
- class="h-8 w-full absolute top-0 left-0 z-50 drag-region"
- data-tauri-drag-region
- ></div>
-
- <!-- App Content -->
- <div class="flex-1 relative overflow-hidden flex flex-col">
- <!-- Views Container -->
- <div class="flex-1 relative overflow-hidden">
- {#if uiState.currentView === "home"}
- <HomeView mouseX={mouseX} mouseY={mouseY} />
- {:else if uiState.currentView === "instances"}
- <InstancesView />
- {:else if uiState.currentView === "versions"}
- <VersionsView />
- {:else if uiState.currentView === "settings"}
- <SettingsView />
- {:else if uiState.currentView === "guide"}
- <AssistantView />
- {/if}
- </div>
-
- <!-- Download Monitor Overlay -->
- <div class="absolute bottom-20 left-4 right-4 pointer-events-none z-20">
- <div class="pointer-events-auto">
- <DownloadMonitor />
- </div>
- </div>
-
- <!-- Bottom Bar -->
- {#if uiState.currentView === "home"}
- <BottomBar />
- {/if}
- </div>
- </main>
- </div>
-
- <LoginModal />
- <StatusToast />
-
- <!-- Logout Confirmation Dialog -->
- {#if authState.isLogoutConfirmOpen}
- <div class="fixed inset-0 z-[200] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
- <div class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200">
- <h3 class="text-lg font-bold text-white mb-2">Logout</h3>
- <p class="text-zinc-400 text-sm mb-6">
- Are you sure you want to logout <span class="text-white font-medium">{authState.currentAccount?.username}</span>?
- </p>
- <div class="flex gap-3 justify-end">
- <button
- onclick={() => authState.cancelLogout()}
- class="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
- >
- Cancel
- </button>
- <button
- onclick={() => authState.confirmLogout()}
- class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors"
- >
- Logout
- </button>
- </div>
- </div>
- </div>
- {/if}
-
- {#if uiState.showConsole}
- <div class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-8">
- <div class="w-full h-full max-w-6xl max-h-[85vh] bg-[#1e1e1e] rounded-lg overflow-hidden border border-zinc-700 shadow-2xl relative flex flex-col">
- <GameConsole />
- </div>
- </div>
- {/if}
-</div>
-
-<style>
- :global(body) {
- margin: 0;
- padding: 0;
- background: #000;
- }
-
- /* Modern Scrollbar */
- :global(*::-webkit-scrollbar) {
- width: 6px;
- height: 6px;
- }
-
- :global(*::-webkit-scrollbar-track) {
- background: transparent;
- }
-
- :global(*::-webkit-scrollbar-thumb) {
- background: rgba(255, 255, 255, 0.1);
- border-radius: 999px;
- }
-
- :global(*::-webkit-scrollbar-thumb:hover) {
- background: rgba(255, 255, 255, 0.25);
- }
-</style>
diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css
deleted file mode 100644
index 63449b7..0000000
--- a/packages/ui/src/app.css
+++ /dev/null
@@ -1,167 +0,0 @@
-@import "tailwindcss";
-
-@variant dark (&:where(.dark, .dark *));
-
-/* ==================== Custom Select/Dropdown Styles ==================== */
-
-/* Base select styling */
-select {
- appearance: none;
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2371717a'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 0.5rem center;
- background-size: 1rem;
- padding-right: 2rem;
-}
-
-/* Option styling - works in WebView/Chromium */
-select option {
- background-color: #18181b;
- color: #e4e4e7;
- padding: 12px 16px;
- font-size: 13px;
- border: none;
-}
-
-select option:hover,
-select option:focus {
- background-color: #3730a3 !important;
- color: white !important;
-}
-
-select option:checked {
- background: linear-gradient(0deg, #4f46e5 0%, #4f46e5 100%);
- color: white;
- font-weight: 500;
-}
-
-select option:disabled {
- color: #52525b;
- background-color: #18181b;
-}
-
-/* Optgroup styling */
-select optgroup {
- background-color: #18181b;
- color: #a1a1aa;
- font-weight: 600;
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- padding: 8px 12px 4px;
-}
-
-/* Select focus state */
-select:focus {
- outline: none;
- border-color: #6366f1;
- box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
-}
-
-/* ==================== Custom Scrollbar (Global) ==================== */
-
-/* Firefox */
-* {
- scrollbar-width: thin;
- scrollbar-color: #3f3f46 transparent;
-}
-
-/* Webkit browsers */
-::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-::-webkit-scrollbar-track {
- background: transparent;
-}
-
-::-webkit-scrollbar-thumb {
- background-color: #3f3f46;
- border-radius: 4px;
- border: 2px solid transparent;
- background-clip: content-box;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background-color: #52525b;
-}
-
-::-webkit-scrollbar-corner {
- background: transparent;
-}
-
-/* ==================== Input/Form Element Consistency ==================== */
-
-input[type="text"],
-input[type="number"],
-input[type="password"],
-input[type="email"],
-textarea {
- background-color: rgba(0, 0, 0, 0.4);
- border: 1px solid rgba(255, 255, 255, 0.1);
- transition:
- border-color 0.2s ease,
- box-shadow 0.2s ease;
-}
-
-input[type="text"]:focus,
-input[type="number"]:focus,
-input[type="password"]:focus,
-input[type="email"]:focus,
-textarea:focus {
- border-color: rgba(99, 102, 241, 0.5);
- box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
- outline: none;
-}
-
-/* Number input - hide spinner */
-input[type="number"]::-webkit-outer-spin-button,
-input[type="number"]::-webkit-inner-spin-button {
- -webkit-appearance: none;
- margin: 0;
-}
-
-input[type="number"] {
- -moz-appearance: textfield;
-}
-
-/* ==================== Checkbox Styling ==================== */
-
-input[type="checkbox"] {
- appearance: none;
- width: 16px;
- height: 16px;
- border: 1px solid #3f3f46;
- border-radius: 4px;
- background-color: #18181b;
- cursor: pointer;
- position: relative;
- transition: all 0.15s ease;
-}
-
-input[type="checkbox"]:hover {
- border-color: #52525b;
-}
-
-input[type="checkbox"]:checked {
- background-color: #4f46e5;
- border-color: #4f46e5;
-}
-
-input[type="checkbox"]:checked::after {
- content: "";
- position: absolute;
- left: 5px;
- top: 2px;
- width: 4px;
- height: 8px;
- border: solid white;
- border-width: 0 2px 2px 0;
- transform: rotate(45deg);
-}
-
-input[type="checkbox"]:focus {
- outline: none;
- box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
-}
diff --git a/packages/ui/src/assets/svelte.svg b/packages/ui/src/assets/svelte.svg
deleted file mode 100644
index 8c056ce..0000000
--- a/packages/ui/src/assets/svelte.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
diff --git a/packages/ui/src/client.ts b/packages/ui/src/client.ts
new file mode 100644
index 0000000..18d2377
--- /dev/null
+++ b/packages/ui/src/client.ts
@@ -0,0 +1,400 @@
+import { invoke } from "@tauri-apps/api/core";
+import type {
+ Account,
+ DeviceCodeResponse,
+ FabricGameVersion,
+ FabricLoaderEntry,
+ FabricLoaderVersion,
+ FileInfo,
+ ForgeVersion,
+ GithubRelease,
+ InstalledFabricVersion,
+ InstalledForgeVersion,
+ InstalledVersion,
+ Instance,
+ JavaCatalog,
+ JavaDownloadInfo,
+ JavaInstallation,
+ LauncherConfig,
+ Message,
+ MigrationResult,
+ ModelInfo,
+ PastebinResponse,
+ PendingJavaDownload,
+ Version,
+ VersionMetadata,
+} from "@/types";
+
+export function assistantChat(messages: Message[]): Promise<Message> {
+ return invoke<Message>("assistant_chat", {
+ messages,
+ });
+}
+
+export function assistantChatStream(messages: Message[]): Promise<string> {
+ return invoke<string>("assistant_chat_stream", {
+ messages,
+ });
+}
+
+export function assistantCheckHealth(): Promise<boolean> {
+ return invoke<boolean>("assistant_check_health");
+}
+
+export function cancelJavaDownload(): Promise<void> {
+ return invoke<void>("cancel_java_download");
+}
+
+export function checkVersionInstalled(
+ instanceId: string,
+ versionId: string,
+): Promise<boolean> {
+ return invoke<boolean>("check_version_installed", {
+ instanceId,
+ versionId,
+ });
+}
+
+export function completeMicrosoftLogin(deviceCode: string): Promise<Account> {
+ return invoke<Account>("complete_microsoft_login", {
+ deviceCode,
+ });
+}
+
+export function createInstance(name: string): Promise<Instance> {
+ return invoke<Instance>("create_instance", {
+ name,
+ });
+}
+
+export function deleteInstance(instanceId: string): Promise<void> {
+ return invoke<void>("delete_instance", {
+ instanceId,
+ });
+}
+
+export function deleteInstanceFile(path: string): Promise<void> {
+ return invoke<void>("delete_instance_file", {
+ path,
+ });
+}
+
+export function deleteVersion(
+ instanceId: string,
+ versionId: string,
+): Promise<void> {
+ return invoke<void>("delete_version", {
+ instanceId,
+ versionId,
+ });
+}
+
+export function detectAllJavaInstallations(): Promise<JavaInstallation[]> {
+ return invoke<JavaInstallation[]>("detect_all_java_installations");
+}
+
+export function detectJava(): Promise<JavaInstallation[]> {
+ return invoke<JavaInstallation[]>("detect_java");
+}
+
+export function downloadAdoptiumJava(
+ majorVersion: number,
+ imageType: string,
+ customPath: string | null,
+): Promise<JavaInstallation> {
+ return invoke<JavaInstallation>("download_adoptium_java", {
+ majorVersion,
+ imageType,
+ customPath,
+ });
+}
+
+export function duplicateInstance(
+ instanceId: string,
+ newName: string,
+): Promise<Instance> {
+ return invoke<Instance>("duplicate_instance", {
+ instanceId,
+ newName,
+ });
+}
+
+export function fetchAdoptiumJava(
+ majorVersion: number,
+ imageType: string,
+): Promise<JavaDownloadInfo> {
+ return invoke<JavaDownloadInfo>("fetch_adoptium_java", {
+ majorVersion,
+ imageType,
+ });
+}
+
+export function fetchAvailableJavaVersions(): Promise<number[]> {
+ return invoke<number[]>("fetch_available_java_versions");
+}
+
+export function fetchJavaCatalog(): Promise<JavaCatalog> {
+ return invoke<JavaCatalog>("fetch_java_catalog");
+}
+
+export function getActiveAccount(): Promise<Account | null> {
+ return invoke<Account | null>("get_active_account");
+}
+
+export function getActiveInstance(): Promise<Instance | null> {
+ return invoke<Instance | null>("get_active_instance");
+}
+
+export function getConfigPath(): Promise<string> {
+ return invoke<string>("get_config_path");
+}
+
+export function getFabricGameVersions(): Promise<FabricGameVersion[]> {
+ return invoke<FabricGameVersion[]>("get_fabric_game_versions");
+}
+
+export function getFabricLoaderVersions(): Promise<FabricLoaderVersion[]> {
+ return invoke<FabricLoaderVersion[]>("get_fabric_loader_versions");
+}
+
+export function getFabricLoadersForVersion(
+ gameVersion: string,
+): Promise<FabricLoaderEntry[]> {
+ return invoke<FabricLoaderEntry[]>("get_fabric_loaders_for_version", {
+ gameVersion,
+ });
+}
+
+export function getForgeGameVersions(): Promise<string[]> {
+ return invoke<string[]>("get_forge_game_versions");
+}
+
+export function getForgeVersionsForGame(
+ gameVersion: string,
+): Promise<ForgeVersion[]> {
+ return invoke<ForgeVersion[]>("get_forge_versions_for_game", {
+ gameVersion,
+ });
+}
+
+export function getGithubReleases(): Promise<GithubRelease[]> {
+ return invoke<GithubRelease[]>("get_github_releases");
+}
+
+export function getInstance(instanceId: string): Promise<Instance> {
+ return invoke<Instance>("get_instance", {
+ instanceId,
+ });
+}
+
+export function getPendingJavaDownloads(): Promise<PendingJavaDownload[]> {
+ return invoke<PendingJavaDownload[]>("get_pending_java_downloads");
+}
+
+export function getRecommendedJava(
+ requiredMajorVersion: number | null,
+): Promise<JavaInstallation | null> {
+ return invoke<JavaInstallation | null>("get_recommended_java", {
+ requiredMajorVersion,
+ });
+}
+
+export function getSettings(): Promise<LauncherConfig> {
+ return invoke<LauncherConfig>("get_settings");
+}
+
+export function getVersionJavaVersion(
+ instanceId: string,
+ versionId: string,
+): Promise<number | null> {
+ return invoke<number | null>("get_version_java_version", {
+ instanceId,
+ versionId,
+ });
+}
+
+export function getVersionMetadata(
+ instanceId: string,
+ versionId: string,
+): Promise<VersionMetadata> {
+ return invoke<VersionMetadata>("get_version_metadata", {
+ instanceId,
+ versionId,
+ });
+}
+
+export function getVersions(): Promise<Version[]> {
+ return invoke<Version[]>("get_versions");
+}
+
+export function getVersionsOfInstance(instanceId: string): Promise<Version[]> {
+ return invoke<Version[]>("get_versions_of_instance", {
+ instanceId,
+ });
+}
+
+export function installFabric(
+ instanceId: string,
+ gameVersion: string,
+ loaderVersion: string,
+): Promise<InstalledFabricVersion> {
+ return invoke<InstalledFabricVersion>("install_fabric", {
+ instanceId,
+ gameVersion,
+ loaderVersion,
+ });
+}
+
+export function installForge(
+ instanceId: string,
+ gameVersion: string,
+ forgeVersion: string,
+): Promise<InstalledForgeVersion> {
+ return invoke<InstalledForgeVersion>("install_forge", {
+ instanceId,
+ gameVersion,
+ forgeVersion,
+ });
+}
+
+export function installVersion(
+ instanceId: string,
+ versionId: string,
+): Promise<void> {
+ return invoke<void>("install_version", {
+ instanceId,
+ versionId,
+ });
+}
+
+export function isFabricInstalled(
+ instanceId: string,
+ gameVersion: string,
+ loaderVersion: string,
+): Promise<boolean> {
+ return invoke<boolean>("is_fabric_installed", {
+ instanceId,
+ gameVersion,
+ loaderVersion,
+ });
+}
+
+export function listInstalledFabricVersions(
+ instanceId: string,
+): Promise<string[]> {
+ return invoke<string[]>("list_installed_fabric_versions", {
+ instanceId,
+ });
+}
+
+export function listInstalledVersions(
+ instanceId: string,
+): Promise<InstalledVersion[]> {
+ return invoke<InstalledVersion[]>("list_installed_versions", {
+ instanceId,
+ });
+}
+
+export function listInstanceDirectory(
+ instanceId: string,
+ folder: string,
+): Promise<FileInfo[]> {
+ return invoke<FileInfo[]>("list_instance_directory", {
+ instanceId,
+ folder,
+ });
+}
+
+export function listInstances(): Promise<Instance[]> {
+ return invoke<Instance[]>("list_instances");
+}
+
+export function listOllamaModels(endpoint: string): Promise<ModelInfo[]> {
+ return invoke<ModelInfo[]>("list_ollama_models", {
+ endpoint,
+ });
+}
+
+export function listOpenaiModels(): Promise<ModelInfo[]> {
+ return invoke<ModelInfo[]>("list_openai_models");
+}
+
+export function loginOffline(username: string): Promise<Account> {
+ return invoke<Account>("login_offline", {
+ username,
+ });
+}
+
+export function logout(): Promise<void> {
+ return invoke<void>("logout");
+}
+
+export function migrateSharedCaches(): Promise<MigrationResult> {
+ return invoke<MigrationResult>("migrate_shared_caches");
+}
+
+export function openFileExplorer(path: string): Promise<void> {
+ return invoke<void>("open_file_explorer", {
+ path,
+ });
+}
+
+export function readRawConfig(): Promise<string> {
+ return invoke<string>("read_raw_config");
+}
+
+export function refreshAccount(): Promise<Account> {
+ return invoke<Account>("refresh_account");
+}
+
+export function refreshJavaCatalog(): Promise<JavaCatalog> {
+ return invoke<JavaCatalog>("refresh_java_catalog");
+}
+
+export function resumeJavaDownloads(): Promise<JavaInstallation[]> {
+ return invoke<JavaInstallation[]>("resume_java_downloads");
+}
+
+export function saveRawConfig(content: string): Promise<void> {
+ return invoke<void>("save_raw_config", {
+ content,
+ });
+}
+
+export function saveSettings(config: LauncherConfig): Promise<void> {
+ return invoke<void>("save_settings", {
+ config,
+ });
+}
+
+export function setActiveInstance(instanceId: string): Promise<void> {
+ return invoke<void>("set_active_instance", {
+ instanceId,
+ });
+}
+
+export function startGame(
+ instanceId: string,
+ versionId: string,
+): Promise<string> {
+ return invoke<string>("start_game", {
+ instanceId,
+ versionId,
+ });
+}
+
+export function startMicrosoftLogin(): Promise<DeviceCodeResponse> {
+ return invoke<DeviceCodeResponse>("start_microsoft_login");
+}
+
+export function updateInstance(instance: Instance): Promise<void> {
+ return invoke<void>("update_instance", {
+ instance,
+ });
+}
+
+export function uploadToPastebin(content: string): Promise<PastebinResponse> {
+ return invoke<PastebinResponse>("upload_to_pastebin", {
+ content,
+ });
+}
diff --git a/packages/ui/src/components/AssistantView.svelte b/packages/ui/src/components/AssistantView.svelte
deleted file mode 100644
index 54509a5..0000000
--- a/packages/ui/src/components/AssistantView.svelte
+++ /dev/null
@@ -1,436 +0,0 @@
-<script lang="ts">
- import { assistantState } from '../stores/assistant.svelte';
- import { settingsState } from '../stores/settings.svelte';
- import { Send, Bot, RefreshCw, Trash2, AlertTriangle, Settings, Brain, ChevronDown } from 'lucide-svelte';
- import { uiState } from '../stores/ui.svelte';
- import { marked } from 'marked';
- import { onMount } from 'svelte';
-
- let input = $state('');
- let messagesContainer: HTMLDivElement | undefined = undefined;
-
- function parseMessageContent(content: string) {
- if (!content) return { thinking: null, content: '', isThinking: false };
-
- // Support both <thinking> and <think> (DeepSeek uses <think>)
- let startTag = '<thinking>';
- let endTag = '</thinking>';
- let startIndex = content.indexOf(startTag);
-
- if (startIndex === -1) {
- startTag = '<think>';
- endTag = '</think>';
- startIndex = content.indexOf(startTag);
- }
-
- // Also check for encoded tags if they weren't decoded properly
- if (startIndex === -1) {
- startTag = '\u003cthink\u003e';
- endTag = '\u003c/think\u003e';
- startIndex = content.indexOf(startTag);
- }
-
- if (startIndex !== -1) {
- const endIndex = content.indexOf(endTag, startIndex);
-
- if (endIndex !== -1) {
- // Completed thinking block
- // We extract the thinking part and keep the rest (before and after)
- const before = content.substring(0, startIndex);
- const thinking = content.substring(startIndex + startTag.length, endIndex).trim();
- const after = content.substring(endIndex + endTag.length);
-
- return {
- thinking,
- content: (before + after).trim(),
- isThinking: false
- };
- } else {
- // Incomplete thinking block (still streaming)
- const before = content.substring(0, startIndex);
- const thinking = content.substring(startIndex + startTag.length).trim();
-
- return {
- thinking,
- content: before.trim(),
- isThinking: true
- };
- }
- }
-
- return { thinking: null, content, isThinking: false };
- }
-
- function renderMarkdown(content: string): string {
- if (!content) return '';
- try {
- // marked.parse returns string synchronously when async is false (default)
- return marked(content, { breaks: true, gfm: true }) as string;
- } catch {
- return content;
- }
- }
-
- function scrollToBottom() {
- if (messagesContainer) {
- setTimeout(() => {
- if (messagesContainer) {
- messagesContainer.scrollTop = messagesContainer.scrollHeight;
- }
- }, 0);
- }
- }
-
- onMount(() => {
- assistantState.init();
- });
-
- // Scroll to bottom when messages change
- $effect(() => {
- // Access reactive state
- const _len = assistantState.messages.length;
- const _processing = assistantState.isProcessing;
- // Scroll on next tick
- if (_len > 0 || _processing) {
- scrollToBottom();
- }
- });
-
- async function handleSubmit() {
- if (!input.trim() || assistantState.isProcessing) return;
- const text = input;
- input = '';
- const provider = settingsState.settings.assistant.llm_provider;
- const endpoint = provider === 'ollama'
- ? settingsState.settings.assistant.ollama_endpoint
- : settingsState.settings.assistant.openai_endpoint;
- await assistantState.sendMessage(
- text,
- settingsState.settings.assistant.enabled,
- provider,
- endpoint
- );
- }
-
- function handleKeydown(e: KeyboardEvent) {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- }
-
- function getProviderName(): string {
- const provider = settingsState.settings.assistant.llm_provider;
- if (provider === 'ollama') {
- return `Ollama (${settingsState.settings.assistant.ollama_model})`;
- } else if (provider === 'openai') {
- return `OpenAI (${settingsState.settings.assistant.openai_model})`;
- }
- return provider;
- }
-
- function getProviderHelpText(): string {
- const provider = settingsState.settings.assistant.llm_provider;
- if (provider === 'ollama') {
- return `Please ensure Ollama is installed and running at ${settingsState.settings.assistant.ollama_endpoint}.`;
- } else if (provider === 'openai') {
- return "Please check your OpenAI API key in Settings > AI Assistant.";
- }
- return "";
- }
-</script>
-
-<div class="h-full w-full flex flex-col gap-4 p-4 lg:p-8 animate-in fade-in zoom-in-95 duration-300">
- <div class="flex items-center justify-between mb-2">
- <div class="flex items-center gap-3">
- <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
- <Bot size={24} />
- </div>
- <div>
- <h2 class="text-2xl font-bold">Game Assistant</h2>
- <p class="text-zinc-400 text-sm">Powered by {getProviderName()}</p>
- </div>
- </div>
-
- <div class="flex items-center gap-2">
- {#if !settingsState.settings.assistant.enabled}
- <div class="flex items-center gap-2 px-3 py-1.5 bg-zinc-500/10 text-zinc-400 rounded-full text-xs font-medium border border-zinc-500/20">
- <AlertTriangle size={14} />
- <span>Disabled</span>
- </div>
- {:else if !assistantState.isProviderHealthy}
- <div class="flex items-center gap-2 px-3 py-1.5 bg-red-500/10 text-red-400 rounded-full text-xs font-medium border border-red-500/20">
- <AlertTriangle size={14} />
- <span>Offline</span>
- </div>
- {:else}
- <div class="flex items-center gap-2 px-3 py-1.5 bg-emerald-500/10 text-emerald-400 rounded-full text-xs font-medium border border-emerald-500/20">
- <div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
- <span>Online</span>
- </div>
- {/if}
-
- <button
- onclick={() => assistantState.checkHealth()}
- class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
- title="Check Connection"
- >
- <RefreshCw size={18} class={assistantState.isProcessing ? "animate-spin" : ""} />
- </button>
-
- <button
- onclick={() => assistantState.clearHistory()}
- class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
- title="Clear History"
- >
- <Trash2 size={18} />
- </button>
-
- <button
- onclick={() => uiState.setView('settings')}
- class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
- title="Settings"
- >
- <Settings size={18} />
- </button>
- </div>
- </div>
-
- <!-- Chat Area -->
- <div class="flex-1 bg-black/20 border border-white/5 rounded-xl overflow-hidden flex flex-col relative">
- {#if assistantState.messages.length === 0}
- <div class="absolute inset-0 flex flex-col items-center justify-center text-zinc-500 gap-4 p-8 text-center">
- <Bot size={48} class="opacity-20" />
- <div class="max-w-md">
- <p class="text-lg font-medium text-zinc-300 mb-2">How can I help you today?</p>
- <p class="text-sm opacity-70">I can analyze your game logs, diagnose crashes, or explain mod features.</p>
- </div>
- {#if !settingsState.settings.assistant.enabled}
- <div class="bg-zinc-500/10 border border-zinc-500/20 rounded-lg p-4 text-sm text-zinc-400 mt-4 max-w-sm">
- Assistant is disabled. Enable it in <button onclick={() => uiState.setView('settings')} class="text-indigo-400 hover:underline">Settings > AI Assistant</button>.
- </div>
- {:else if !assistantState.isProviderHealthy}
- <div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-sm text-red-400 mt-4 max-w-sm">
- {getProviderHelpText()}
- </div>
- {/if}
- </div>
- {/if}
-
- <div
- bind:this={messagesContainer}
- class="flex-1 overflow-y-auto p-4 space-y-4 scroll-smooth"
- >
- {#each assistantState.messages as msg, idx}
- <div class="flex gap-3 {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
- {#if msg.role === 'assistant'}
- <div class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-400 shrink-0 mt-1">
- <Bot size={16} />
- </div>
- {/if}
-
- <div class="max-w-[80%] p-3 rounded-2xl {msg.role === 'user' ? 'bg-indigo-600 text-white rounded-tr-none' : 'bg-zinc-800/50 text-zinc-200 rounded-tl-none border border-white/5'}">
- {#if msg.role === 'user'}
- <div class="break-words whitespace-pre-wrap">
- {msg.content}
- </div>
- {:else}
- {@const parsed = parseMessageContent(msg.content)}
-
- <!-- Thinking Block -->
- {#if parsed.thinking}
- <div class="mb-3 max-w-full overflow-hidden">
- <details class="group" open={parsed.isThinking}>
- <summary class="list-none cursor-pointer flex items-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors text-xs font-medium select-none bg-black/20 p-2 rounded-lg border border-white/5 w-fit mb-2 outline-none">
- <Brain size={14} />
- <span>Thinking Process</span>
- <ChevronDown size={14} class="transition-transform duration-200 group-open:rotate-180" />
- </summary>
- <div class="pl-3 border-l-2 border-zinc-700 text-zinc-500 text-xs italic leading-relaxed whitespace-pre-wrap font-mono max-h-96 overflow-y-auto custom-scrollbar bg-black/10 p-2 rounded-r-md">
- {parsed.thinking}
- {#if parsed.isThinking}
- <span class="inline-block w-1.5 h-3 bg-zinc-500 ml-1 animate-pulse align-middle"></span>
- {/if}
- </div>
- </details>
- </div>
- {/if}
-
- <!-- Markdown rendered content for assistant -->
- <div class="markdown-content prose prose-invert prose-sm max-w-none">
- {#if parsed.content}
- {@html renderMarkdown(parsed.content)}
- {:else if assistantState.isProcessing && idx === assistantState.messages.length - 1 && !parsed.isThinking}
- <span class="inline-flex items-center gap-1">
- <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse"></span>
- <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse" style="animation-delay: 0.2s"></span>
- <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse" style="animation-delay: 0.4s"></span>
- </span>
- {/if}
- </div>
-
- <!-- Generation Stats -->
- {#if msg.stats}
- <div class="mt-3 pt-3 border-t border-white/5 text-[10px] text-zinc-500 font-mono flex flex-wrap gap-x-4 gap-y-1 opacity-70 hover:opacity-100 transition-opacity select-none">
- <div class="flex gap-1" title="Tokens generated">
- <span>Eval:</span>
- <span class="text-zinc-400">{msg.stats.eval_count} tokens</span>
- </div>
- <div class="flex gap-1" title="Total duration">
- <span>Time:</span>
- <span class="text-zinc-400">{(msg.stats.total_duration / 1e9).toFixed(2)}s</span>
- </div>
- {#if msg.stats.eval_duration > 0}
- <div class="flex gap-1" title="Generation speed">
- <span>Speed:</span>
- <span class="text-zinc-400">{(msg.stats.eval_count / (msg.stats.eval_duration / 1e9)).toFixed(1)} t/s</span>
- </div>
- {/if}
- </div>
- {/if}
- {/if}
- </div>
- </div>
- {/each}
- </div>
-
- <!-- Input Area -->
- <div class="p-4 bg-zinc-900/50 border-t border-white/5">
- <div class="relative">
- <textarea
- bind:value={input}
- onkeydown={handleKeydown}
- placeholder={settingsState.settings.assistant.enabled ? "Ask about your game..." : "Assistant is disabled..."}
- class="w-full bg-black/20 border border-white/10 rounded-xl py-3 pl-4 pr-12 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 resize-none h-[52px] max-h-32 transition-all text-white disabled:opacity-50"
- disabled={assistantState.isProcessing || !settingsState.settings.assistant.enabled}
- ></textarea>
-
- <button
- onclick={handleSubmit}
- disabled={!input.trim() || assistantState.isProcessing || !settingsState.settings.assistant.enabled}
- class="absolute right-2 top-2 p-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:hover:bg-indigo-600 text-white rounded-lg transition-colors"
- >
- <Send size={16} />
- </button>
- </div>
- </div>
- </div>
-</div>
-
-<style>
- /* Markdown content styles */
- .markdown-content :global(p) {
- margin-bottom: 0.5rem;
- }
-
- .markdown-content :global(p:last-child) {
- margin-bottom: 0;
- }
-
- .markdown-content :global(pre) {
- background-color: rgba(0, 0, 0, 0.4);
- border-radius: 0.5rem;
- padding: 0.75rem;
- overflow-x: auto;
- margin: 0.5rem 0;
- }
-
- .markdown-content :global(code) {
- font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
- font-size: 0.85em;
- }
-
- .markdown-content :global(pre code) {
- background: none;
- padding: 0;
- }
-
- .markdown-content :global(:not(pre) > code) {
- background-color: rgba(0, 0, 0, 0.3);
- padding: 0.15rem 0.4rem;
- border-radius: 0.25rem;
- }
-
- .markdown-content :global(ul),
- .markdown-content :global(ol) {
- margin: 0.5rem 0;
- padding-left: 1.5rem;
- }
-
- .markdown-content :global(li) {
- margin: 0.25rem 0;
- }
-
- .markdown-content :global(blockquote) {
- border-left: 3px solid rgba(99, 102, 241, 0.5);
- padding-left: 1rem;
- margin: 0.5rem 0;
- color: rgba(255, 255, 255, 0.7);
- }
-
- .markdown-content :global(h1),
- .markdown-content :global(h2),
- .markdown-content :global(h3),
- .markdown-content :global(h4) {
- font-weight: 600;
- margin: 0.75rem 0 0.5rem 0;
- }
-
- .markdown-content :global(h1) {
- font-size: 1.25rem;
- }
-
- .markdown-content :global(h2) {
- font-size: 1.125rem;
- }
-
- .markdown-content :global(h3) {
- font-size: 1rem;
- }
-
- .markdown-content :global(a) {
- color: rgb(129, 140, 248);
- text-decoration: underline;
- }
-
- .markdown-content :global(a:hover) {
- color: rgb(165, 180, 252);
- }
-
- .markdown-content :global(table) {
- border-collapse: collapse;
- margin: 0.5rem 0;
- width: 100%;
- }
-
- .markdown-content :global(th),
- .markdown-content :global(td) {
- border: 1px solid rgba(255, 255, 255, 0.1);
- padding: 0.5rem;
- text-align: left;
- }
-
- .markdown-content :global(th) {
- background-color: rgba(0, 0, 0, 0.3);
- font-weight: 600;
- }
-
- .markdown-content :global(hr) {
- border: none;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
- margin: 1rem 0;
- }
-
- .markdown-content :global(img) {
- max-width: 100%;
- border-radius: 0.5rem;
- }
-
- .markdown-content :global(strong) {
- font-weight: 600;
- }
-
- .markdown-content :global(em) {
- font-style: italic;
- }
-</style>
diff --git a/packages/ui/src/components/BottomBar.svelte b/packages/ui/src/components/BottomBar.svelte
deleted file mode 100644
index 19cf35d..0000000
--- a/packages/ui/src/components/BottomBar.svelte
+++ /dev/null
@@ -1,250 +0,0 @@
-<script lang="ts">
- import { invoke } from "@tauri-apps/api/core";
- import { listen, type UnlistenFn } from "@tauri-apps/api/event";
- import { authState } from "../stores/auth.svelte";
- import { gameState } from "../stores/game.svelte";
- import { uiState } from "../stores/ui.svelte";
- import { instancesState } from "../stores/instances.svelte";
- import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte';
-
- interface InstalledVersion {
- id: string;
- type: string;
- }
-
- let isVersionDropdownOpen = $state(false);
- let dropdownRef: HTMLDivElement;
- let installedVersions = $state<InstalledVersion[]>([]);
- let isLoadingVersions = $state(true);
- let downloadCompleteUnlisten: UnlistenFn | null = null;
- let versionDeletedUnlisten: UnlistenFn | null = null;
-
- // Load installed versions on mount
- $effect(() => {
- loadInstalledVersions();
- setupEventListeners();
- return () => {
- if (downloadCompleteUnlisten) {
- downloadCompleteUnlisten();
- }
- if (versionDeletedUnlisten) {
- versionDeletedUnlisten();
- }
- };
- });
-
- async function setupEventListeners() {
- // Refresh list when a download completes
- downloadCompleteUnlisten = await listen("download-complete", () => {
- loadInstalledVersions();
- });
- // Refresh list when a version is deleted
- versionDeletedUnlisten = await listen("version-deleted", () => {
- loadInstalledVersions();
- });
- }
-
- async function loadInstalledVersions() {
- if (!instancesState.activeInstanceId) {
- installedVersions = [];
- isLoadingVersions = false;
- return;
- }
- isLoadingVersions = true;
- try {
- installedVersions = await invoke<InstalledVersion[]>("list_installed_versions", {
- instanceId: instancesState.activeInstanceId,
- });
- // If no version is selected but we have installed versions, select the first one
- if (!gameState.selectedVersion && installedVersions.length > 0) {
- gameState.selectedVersion = installedVersions[0].id;
- }
- } catch (e) {
- console.error("Failed to load installed versions:", e);
- } finally {
- isLoadingVersions = false;
- }
- }
-
- let versionOptions = $derived(
- isLoadingVersions
- ? [{ id: "loading", type: "loading", label: "Loading..." }]
- : installedVersions.length === 0
- ? [{ id: "empty", type: "empty", label: "No versions installed" }]
- : installedVersions.map(v => ({
- ...v,
- label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}`
- }))
- );
-
- function selectVersion(id: string) {
- if (id !== "loading" && id !== "empty") {
- gameState.selectedVersion = id;
- isVersionDropdownOpen = false;
- }
- }
-
- function handleClickOutside(e: MouseEvent) {
- if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
- isVersionDropdownOpen = false;
- }
- }
-
- $effect(() => {
- if (isVersionDropdownOpen) {
- document.addEventListener('click', handleClickOutside);
- return () => document.removeEventListener('click', handleClickOutside);
- }
- });
-
- function getVersionTypeColor(type: string) {
- switch (type) {
- case 'fabric': return 'text-indigo-400';
- case 'forge': return 'text-orange-400';
- case 'snapshot': return 'text-amber-400';
- case 'modpack': return 'text-purple-400';
- default: return 'text-emerald-400';
- }
- }
-</script>
-
-<div
- class="h-20 bg-white/80 dark:bg-[#09090b]/90 border-t dark:border-white/10 border-black/5 flex items-center px-8 justify-between z-20 backdrop-blur-md"
->
- <!-- Account Area -->
- <div class="flex items-center gap-6">
- <div
- class="group flex items-center gap-4 cursor-pointer"
- onclick={() => authState.openLoginModal()}
- role="button"
- tabindex="0"
- onkeydown={(e) => e.key === "Enter" && authState.openLoginModal()}
- >
- <div
- class="w-10 h-10 rounded-sm bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 flex items-center justify-center overflow-hidden transition-all group-hover:border-zinc-400 dark:group-hover:border-zinc-500"
- >
- {#if authState.currentAccount}
- <img
- src={`https://minotar.net/avatar/${authState.currentAccount.username}/48`}
- alt={authState.currentAccount.username}
- class="w-full h-full"
- />
- {:else}
- <User size={20} class="text-zinc-400" />
- {/if}
- </div>
- <div>
- <div class="font-bold dark:text-white text-gray-900 text-sm group-hover:text-black dark:group-hover:text-zinc-200 transition-colors">
- {authState.currentAccount ? authState.currentAccount.username : "Login Account"}
- </div>
- <div class="text-[10px] uppercase tracking-wider dark:text-zinc-500 text-gray-500 flex items-center gap-2">
- {#if authState.currentAccount}
- {#if authState.currentAccount.type === "Microsoft"}
- {#if authState.currentAccount.expires_at && authState.currentAccount.expires_at * 1000 < Date.now()}
- <span class="w-1.5 h-1.5 rounded-full bg-red-500"></span>
- <span class="text-red-400">Expired</span>
- {:else}
- <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
- Online
- {/if}
- {:else}
- <span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span>
- Offline
- {/if}
- {:else}
- <span class="w-1.5 h-1.5 rounded-full bg-zinc-400"></span>
- Guest
- {/if}
- </div>
- </div>
- </div>
-
- <div class="h-8 w-px dark:bg-white/10 bg-black/10"></div>
-
- <!-- Console Toggle -->
- <button
- class="text-xs font-mono dark:text-zinc-500 text-gray-500 dark:hover:text-white hover:text-black transition-colors flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-black/5 dark:hover:bg-white/5"
- onclick={() => uiState.toggleConsole()}
- >
- <Terminal size={14} />
- {uiState.showConsole ? "HIDE LOGS" : "SHOW LOGS"}
- </button>
- </div>
-
- <!-- Action Area -->
- <div class="flex items-center gap-4">
- <div class="flex flex-col items-end mr-2">
- <!-- Custom Version Dropdown -->
- <div class="relative" bind:this={dropdownRef}>
- <button
- type="button"
- onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen}
- disabled={installedVersions.length === 0 && !isLoadingVersions}
- class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left
- dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md
- text-sm font-mono dark:text-white text-gray-900
- dark:hover:border-zinc-600 hover:border-zinc-400
- focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
- transition-colors cursor-pointer outline-none
- disabled:opacity-50 disabled:cursor-not-allowed"
- >
- <span class="truncate">
- {#if isLoadingVersions}
- Loading...
- {:else if installedVersions.length === 0}
- No versions installed
- {:else}
- {gameState.selectedVersion || "Select version"}
- {/if}
- </span>
- <ChevronDown
- size={14}
- class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}"
- />
- </button>
-
- {#if isVersionDropdownOpen && installedVersions.length > 0}
- <div
- class="absolute z-50 w-full mt-1 py-1 dark:bg-zinc-900 bg-white border dark:border-zinc-700 border-zinc-300 rounded-md shadow-xl
- max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1 right-0"
- >
- {#each versionOptions as version}
- <button
- type="button"
- onclick={() => selectVersion(version.id)}
- disabled={version.id === "loading" || version.id === "empty"}
- class="w-full flex items-center justify-between px-3 py-2 text-sm font-mono text-left
- transition-colors outline-none
- {version.id === gameState.selectedVersion
- ? 'bg-indigo-600 text-white'
- : 'dark:text-zinc-300 text-gray-700 dark:hover:bg-zinc-800 hover:bg-zinc-100'}
- {version.id === 'loading' || version.id === 'empty' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
- >
- <span class="truncate flex items-center gap-2">
- {version.id}
- {#if version.type && version.type !== 'release' && version.type !== 'loading' && version.type !== 'empty'}
- <span class="text-[10px] uppercase {version.id === gameState.selectedVersion ? 'text-white/70' : getVersionTypeColor(version.type)}">
- {version.type}
- </span>
- {/if}
- </span>
- {#if version.id === gameState.selectedVersion}
- <Check size={14} class="shrink-0 ml-2" />
- {/if}
- </button>
- {/each}
- </div>
- {/if}
- </div>
- </div>
-
- <button
- onclick={() => gameState.startGame()}
- disabled={installedVersions.length === 0 || !gameState.selectedVersion}
- class="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase"
- >
- <Play size={24} fill="currentColor" />
- <span>Launch</span>
- </button>
- </div>
-</div>
diff --git a/packages/ui/src/components/ConfigEditorModal.svelte b/packages/ui/src/components/ConfigEditorModal.svelte
deleted file mode 100644
index dd866ee..0000000
--- a/packages/ui/src/components/ConfigEditorModal.svelte
+++ /dev/null
@@ -1,369 +0,0 @@
-<script lang="ts">
- import { settingsState } from "../stores/settings.svelte";
- import { Save, X, FileJson, AlertCircle, Undo, Redo, Settings } from "lucide-svelte";
- import Prism from 'prismjs';
- import 'prismjs/components/prism-json';
- import 'prismjs/themes/prism-tomorrow.css';
-
- let content = $state(settingsState.rawConfigContent);
- let isSaving = $state(false);
- let localError = $state("");
-
- let textareaRef: HTMLTextAreaElement | undefined = $state();
- let preRef: HTMLPreElement | undefined = $state();
- let lineNumbersRef: HTMLDivElement | undefined = $state();
-
- // Textarea attributes that TypeScript doesn't recognize but are valid HTML
- const textareaAttrs = {
- autocorrect: "off",
- autocapitalize: "off"
- } as Record<string, string>;
-
- // History State
- let history = $state([settingsState.rawConfigContent]);
- let historyIndex = $state(0);
- let debounceTimer: ReturnType<typeof setTimeout> | undefined;
-
- // Editor Settings
- let showLineNumbers = $state(localStorage.getItem('editor_showLineNumbers') !== 'false');
- let showStatusBar = $state(localStorage.getItem('editor_showStatusBar') !== 'false');
- let showSettings = $state(false);
-
- // Cursor Status
- let cursorLine = $state(1);
- let cursorCol = $state(1);
-
- let lines = $derived(content.split('\n'));
-
- $effect(() => {
- localStorage.setItem('editor_showLineNumbers', String(showLineNumbers));
- localStorage.setItem('editor_showStatusBar', String(showStatusBar));
- });
-
- // Cleanup timer on destroy
- $effect(() => {
- return () => {
- if (debounceTimer) clearTimeout(debounceTimer);
- };
- });
-
- // Initial validation
- $effect(() => {
- validate(content);
- });
-
- function validate(text: string) {
- try {
- JSON.parse(text);
- localError = "";
- } catch (e: any) {
- localError = e.message;
- }
- }
-
- function pushHistory(newContent: string, immediate = false) {
- if (debounceTimer) clearTimeout(debounceTimer);
-
- const commit = () => {
- if (newContent === history[historyIndex]) return;
- const next = history.slice(0, historyIndex + 1);
- next.push(newContent);
- history = next;
- historyIndex = next.length - 1;
- };
-
- if (immediate) {
- commit();
- } else {
- debounceTimer = setTimeout(commit, 500);
- }
- }
-
- function handleUndo() {
- if (historyIndex > 0) {
- historyIndex--;
- content = history[historyIndex];
- validate(content);
- }
- }
-
- function handleRedo() {
- if (historyIndex < history.length - 1) {
- historyIndex++;
- content = history[historyIndex];
- validate(content);
- }
- }
-
- function updateCursor() {
- if (!textareaRef) return;
- const pos = textareaRef.selectionStart;
- const text = textareaRef.value.substring(0, pos);
- const lines = text.split('\n');
- cursorLine = lines.length;
- cursorCol = lines[lines.length - 1].length + 1;
- }
-
- function handleInput(e: Event) {
- const target = e.target as HTMLTextAreaElement;
- content = target.value;
- validate(content);
- pushHistory(content);
- updateCursor();
- }
-
- function handleScroll() {
- if (textareaRef) {
- if (preRef) {
- preRef.scrollTop = textareaRef.scrollTop;
- preRef.scrollLeft = textareaRef.scrollLeft;
- }
- if (lineNumbersRef) {
- lineNumbersRef.scrollTop = textareaRef.scrollTop;
- }
- }
- }
-
- let highlightedCode = $derived(
- Prism.highlight(content, Prism.languages.json, 'json') + '\n'
- );
-
- async function handleSave(close = false) {
- if (localError) return;
- isSaving = true;
- await settingsState.saveRawConfig(content, close);
- isSaving = false;
- }
-
- function handleKeydown(e: KeyboardEvent) {
- // Save
- if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
- e.preventDefault();
- handleSave(false); // Keep open on shortcut save
- }
- // Undo
- else if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
- e.preventDefault();
- handleUndo();
- }
- // Redo (Ctrl+Shift+Z or Ctrl+Y)
- else if (
- (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey) ||
- (e.key === 'y' && (e.ctrlKey || e.metaKey))
- ) {
- e.preventDefault();
- handleRedo();
- }
- // Close
- else if (e.key === 'Escape') {
- settingsState.closeConfigEditor();
- }
- // Tab
- else if (e.key === 'Tab') {
- e.preventDefault();
- const target = e.target as HTMLTextAreaElement;
- const start = target.selectionStart;
- const end = target.selectionEnd;
-
- pushHistory(content, true);
-
- const newContent = content.substring(0, start) + " " + content.substring(end);
- content = newContent;
-
- pushHistory(content, true);
-
- setTimeout(() => {
- target.selectionStart = target.selectionEnd = start + 2;
- updateCursor();
- }, 0);
- validate(content);
- }
- }
-</script>
-
-<div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70 animate-in fade-in duration-200">
- <div
- class="bg-[#1d1f21] rounded-xl border border-zinc-700 shadow-2xl w-[900px] max-w-[95vw] h-[85vh] flex flex-col overflow-hidden"
- role="dialog"
- aria-modal="true"
- >
- <!-- Header -->
- <div class="flex items-center justify-between p-4 border-b border-zinc-700 bg-[#1d1f21] z-20 relative">
- <div class="flex items-center gap-3">
- <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
- <FileJson size={20} />
- </div>
- <div class="flex flex-col">
- <h3 class="text-lg font-bold text-white leading-none">Configuration Editor</h3>
- <span class="text-[10px] text-zinc-500 font-mono mt-1 break-all">{settingsState.configFilePath}</span>
- </div>
- </div>
- <div class="flex items-center gap-2">
- <!-- Undo/Redo Buttons -->
- <div class="flex items-center bg-zinc-800 rounded-lg p-0.5 mr-2 border border-zinc-700">
- <button
- onclick={handleUndo}
- disabled={historyIndex === 0}
- class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
- title="Undo (Ctrl+Z)"
- >
- <Undo size={16} />
- </button>
- <button
- onclick={handleRedo}
- disabled={historyIndex === history.length - 1}
- class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
- title="Redo (Ctrl+Y)"
- >
- <Redo size={16} />
- </button>
- </div>
-
- <!-- Settings Toggle -->
- <div class="relative">
- <button
- onclick={() => showSettings = !showSettings}
- class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg {showSettings ? 'bg-white/10 text-white' : ''}"
- title="Editor Settings"
- >
- <Settings size={20} />
- </button>
-
- {#if showSettings}
- <div class="absolute right-0 top-full mt-2 w-48 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl p-2 z-50 flex flex-col gap-1">
- <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer">
- <input type="checkbox" bind:checked={showLineNumbers} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" />
- <span class="text-sm text-zinc-300">Line Numbers</span>
- </label>
- <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer">
- <input type="checkbox" bind:checked={showStatusBar} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" />
- <span class="text-sm text-zinc-300">Cursor Status</span>
- </label>
- </div>
- {/if}
- </div>
-
- <button
- onclick={() => settingsState.closeConfigEditor()}
- class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg"
- title="Close (Esc)"
- >
- <X size={20} />
- </button>
- </div>
- </div>
-
- <!-- Error Banner -->
- {#if localError || settingsState.configEditorError}
- <div class="bg-red-500/10 border-b border-red-500/20 p-3 flex items-start gap-3 animate-in slide-in-from-top-2 z-10 relative">
- <AlertCircle size={16} class="text-red-400 mt-0.5 shrink-0" />
- <p class="text-xs text-red-300 font-mono whitespace-pre-wrap">{localError || settingsState.configEditorError}</p>
- </div>
- {/if}
-
- <!-- Editor Body (Flex row for line numbers + code) -->
- <div class="flex-1 flex overflow-hidden relative bg-[#1d1f21]">
- <!-- Line Numbers -->
- {#if showLineNumbers}
- <div
- bind:this={lineNumbersRef}
- class="pt-4 pb-4 pr-3 pl-2 text-right text-zinc-600 bg-[#1d1f21] border-r border-zinc-700/50 font-mono select-none overflow-hidden min-w-[3rem]"
- aria-hidden="true"
- >
- {#each lines as _, i}
- <div class="leading-[20px] text-[13px]">{i + 1}</div>
- {/each}
- </div>
- {/if}
-
- <!-- Code Area -->
- <div class="flex-1 relative overflow-hidden group">
- <!-- Highlighted Code (Background) -->
- <pre
- bind:this={preRef}
- aria-hidden="true"
- class="absolute inset-0 w-full h-full p-4 m-0 bg-transparent pointer-events-none overflow-hidden whitespace-pre font-mono text-sm leading-relaxed"
- ><code class="language-json">{@html highlightedCode}</code></pre>
-
- <!-- Textarea (Foreground) -->
- <textarea
- bind:this={textareaRef}
- bind:value={content}
- oninput={handleInput}
- onkeydown={handleKeydown}
- onscroll={handleScroll}
- onmouseup={updateCursor}
- onkeyup={updateCursor}
- onclick={() => showSettings = false}
- class="absolute inset-0 w-full h-full p-4 bg-transparent text-transparent caret-white font-mono text-sm leading-relaxed resize-none focus:outline-none whitespace-pre overflow-auto z-10 selection:bg-indigo-500/30"
- spellcheck="false"
- {...textareaAttrs}
- ></textarea>
- </div>
- </div>
-
- <!-- Footer -->
- <div class="p-3 border-t border-zinc-700 bg-[#1d1f21] flex justify-between items-center z-20 relative">
- <div class="text-xs text-zinc-500 flex gap-4 items-center">
- {#if showStatusBar}
- <div class="flex gap-3 font-mono border-r border-zinc-700 pr-4 mr-1">
- <span>Ln {cursorLine}</span>
- <span>Col {cursorCol}</span>
- </div>
- {/if}
- <span class="hidden sm:inline"><span class="bg-white/10 px-1.5 py-0.5 rounded text-zinc-300">Ctrl+S</span> save</span>
- </div>
- <div class="flex gap-3">
- <button
- onclick={() => settingsState.closeConfigEditor()}
- class="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-medium transition-colors"
- >
- Cancel
- </button>
- <button
- onclick={() => handleSave(false)}
- disabled={isSaving || !!localError}
- class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
- title={localError ? "Fix errors before saving" : "Save changes"}
- >
- {#if isSaving}
- <div class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
- Saving...
- {:else}
- <Save size={16} />
- Save
- {/if}
- </button>
- </div>
- </div>
- </div>
-</div>
-
-<style>
- /* Ensure exact font match */
- pre, textarea {
- font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
- font-size: 13px !important;
- line-height: 20px !important;
- letter-spacing: 0px !important;
- tab-size: 2;
- }
-
- /* Hide scrollbar for pre but keep it functional for textarea */
- pre::-webkit-scrollbar {
- display: none;
- }
-
- /* Override Prism background and font weights for alignment */
- :global(pre[class*="language-"]), :global(code[class*="language-"]) {
- background: transparent !important;
- text-shadow: none !important;
- box-shadow: none !important;
- }
-
- /* CRITICAL: Force normal weight to match textarea */
- :global(.token) {
- font-weight: normal !important;
- font-style: normal !important;
- }
-</style>
diff --git a/packages/ui/src/components/CustomSelect.svelte b/packages/ui/src/components/CustomSelect.svelte
deleted file mode 100644
index 0767471..0000000
--- a/packages/ui/src/components/CustomSelect.svelte
+++ /dev/null
@@ -1,173 +0,0 @@
-<script lang="ts">
- import { ChevronDown, Check } from 'lucide-svelte';
-
- interface Option {
- value: string;
- label: string;
- disabled?: boolean;
- }
-
- interface Props {
- options: Option[];
- value: string;
- placeholder?: string;
- disabled?: boolean;
- class?: string;
- allowCustom?: boolean; // New prop to allow custom input
- onchange?: (value: string) => void;
- }
-
- let {
- options,
- value = $bindable(),
- placeholder = "Select...",
- disabled = false,
- class: className = "",
- allowCustom = false,
- onchange
- }: Props = $props();
-
- let isOpen = $state(false);
- let containerRef: HTMLDivElement;
- let customInput = $state(""); // State for custom input
-
- let selectedOption = $derived(options.find(o => o.value === value));
- // Display label: if option exists use its label, otherwise if custom is allowed use raw value, else placeholder
- let displayLabel = $derived(selectedOption ? selectedOption.label : (allowCustom && value ? value : placeholder));
-
- function toggle() {
- if (!disabled) {
- isOpen = !isOpen;
- // When opening, if current value is custom (not in options), pre-fill input
- if (isOpen && allowCustom && !selectedOption) {
- customInput = value;
- }
- }
- }
-
- function select(option: Option) {
- if (option.disabled) return;
- value = option.value;
- isOpen = false;
- onchange?.(option.value);
- }
-
- function handleCustomSubmit() {
- if (!customInput.trim()) return;
- value = customInput.trim();
- isOpen = false;
- onchange?.(value);
- }
-
- function handleKeydown(e: KeyboardEvent) {
- if (disabled) return;
-
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- toggle();
- } else if (e.key === 'Escape') {
- isOpen = false;
- } else if (e.key === 'ArrowDown' && isOpen) {
- e.preventDefault();
- const currentIndex = options.findIndex(o => o.value === value);
- const nextIndex = Math.min(currentIndex + 1, options.length - 1);
- if (!options[nextIndex].disabled) {
- value = options[nextIndex].value;
- }
- } else if (e.key === 'ArrowUp' && isOpen) {
- e.preventDefault();
- const currentIndex = options.findIndex(o => o.value === value);
- const prevIndex = Math.max(currentIndex - 1, 0);
- if (!options[prevIndex].disabled) {
- value = options[prevIndex].value;
- }
- }
- }
-
- function handleClickOutside(e: MouseEvent) {
- if (containerRef && !containerRef.contains(e.target as Node)) {
- isOpen = false;
- }
- }
-
- $effect(() => {
- if (isOpen) {
- document.addEventListener('click', handleClickOutside);
- return () => document.removeEventListener('click', handleClickOutside);
- }
- });
-</script>
-
-<div
- bind:this={containerRef}
- class="relative {className}"
->
- <!-- Trigger Button -->
- <button
- type="button"
- onclick={toggle}
- onkeydown={handleKeydown}
- {disabled}
- class="w-full flex items-center justify-between gap-2 px-3 py-2 pr-8 text-left
- bg-zinc-900 border border-zinc-700 rounded-md text-sm text-zinc-200
- hover:border-zinc-600 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
- transition-colors cursor-pointer outline-none
- disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-700"
- >
- <span class="truncate {(!selectedOption && !value) ? 'text-zinc-500' : ''}">
- {displayLabel}
- </span>
- <ChevronDown
- size={14}
- class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}"
- />
- </button>
-
- <!-- Dropdown Menu -->
- {#if isOpen}
- <div
- class="absolute z-50 w-full mt-1 py-1 bg-zinc-900 border border-zinc-700 rounded-md shadow-xl
- max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 flex flex-col"
- >
- {#if allowCustom}
- <div class="px-2 py-2 border-b border-zinc-700/50 mb-1">
- <div class="flex gap-2">
- <input
- type="text"
- bind:value={customInput}
- placeholder="Custom value..."
- class="flex-1 bg-black/30 border border-zinc-700 rounded px-2 py-1 text-xs text-white focus:border-indigo-500 outline-none"
- onkeydown={(e) => e.key === 'Enter' && handleCustomSubmit()}
- onclick={(e) => e.stopPropagation()}
- />
- <button
- onclick={(e) => { e.stopPropagation(); handleCustomSubmit(); }}
- class="px-2 py-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded text-xs transition-colors"
- >
- Set
- </button>
- </div>
- </div>
- {/if}
-
- {#each options as option}
- <button
- type="button"
- onclick={() => select(option)}
- disabled={option.disabled}
- class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
- transition-colors outline-none
- {option.value === value
- ? 'bg-indigo-600 text-white'
- : 'text-zinc-300 hover:bg-zinc-800'}
- {option.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}"
- >
- <span class="truncate">{option.label}</span>
- {#if option.value === value}
- <Check size={14} class="shrink-0 ml-2" />
- {/if}
- </button>
- {/each}
- </div>
- {/if}
-</div>
diff --git a/packages/ui/src/components/HomeView.svelte b/packages/ui/src/components/HomeView.svelte
deleted file mode 100644
index 573d9da..0000000
--- a/packages/ui/src/components/HomeView.svelte
+++ /dev/null
@@ -1,271 +0,0 @@
-<script lang="ts">
- import { onMount } from 'svelte';
- import { gameState } from '../stores/game.svelte';
- import { releasesState } from '../stores/releases.svelte';
- import { Calendar, ExternalLink } from 'lucide-svelte';
- import { getSaturnEffect } from './ParticleBackground.svelte';
-
- type Props = {
- mouseX: number;
- mouseY: number;
- };
- let { mouseX = 0, mouseY = 0 }: Props = $props();
-
- // Saturn effect mouse interaction handlers
- function handleSaturnMouseDown(e: MouseEvent) {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleMouseDown(e.clientX);
- }
- }
-
- function handleSaturnMouseMove(e: MouseEvent) {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleMouseMove(e.clientX);
- }
- }
-
- function handleSaturnMouseUp() {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleMouseUp();
- }
- }
-
- function handleSaturnMouseLeave() {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleMouseUp();
- }
- }
-
- function handleSaturnTouchStart(e: TouchEvent) {
- if (e.touches.length === 1) {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleTouchStart(e.touches[0].clientX);
- }
- }
- }
-
- function handleSaturnTouchMove(e: TouchEvent) {
- if (e.touches.length === 1) {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleTouchMove(e.touches[0].clientX);
- }
- }
- }
-
- function handleSaturnTouchEnd() {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleTouchEnd();
- }
- }
-
- onMount(() => {
- releasesState.loadReleases();
- });
-
- function formatDate(dateString: string) {
- return new Date(dateString).toLocaleDateString(undefined, {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
- }
-
- function escapeHtml(unsafe: string) {
- return unsafe
- .replace(/&/g, "&amp;")
- .replace(/</g, "&lt;")
- .replace(/>/g, "&gt;")
- .replace(/"/g, "&quot;")
- .replace(/'/g, "&#039;");
- }
-
- // Enhanced markdown parser with Emoji and GitHub specific features
- function formatBody(body: string) {
- if (!body) return '';
-
- // Escape HTML first to prevent XSS
- let processed = escapeHtml(body);
-
- // Emoji map (common GitHub emojis)
- const emojiMap: Record<string, string> = {
- ':tada:': '🎉', ':sparkles:': '✨', ':bug:': '🐛', ':memo:': '📝',
- ':rocket:': '🚀', ':white_check_mark:': '✅', ':construction:': '🚧',
- ':recycle:': '♻️', ':wrench:': '🔧', ':package:': '📦',
- ':arrow_up:': '⬆️', ':arrow_down:': '⬇️', ':warning:': '⚠️',
- ':fire:': '🔥', ':heart:': '❤️', ':star:': '⭐', ':zap:': '⚡',
- ':art:': '🎨', ':lipstick:': '💄', ':globe_with_meridians:': '🌐'
- };
-
- // Replace emojis
- processed = processed.replace(/:[a-z0-9_]+:/g, (match) => emojiMap[match] || match);
-
- // GitHub commit hash linking (simple version for 7-40 hex chars inside backticks)
- processed = processed.replace(/`([0-9a-f]{7,40})`/g, (match, hash) => {
- return `<a href="https://github.com/HydroRoll-Team/DropOut/commit/${hash}" target="_blank" class="text-emerald-500 hover:underline font-mono bg-emerald-500/10 px-1 rounded text-[10px] py-0.5 transition-colors border border-emerald-500/20 hover:border-emerald-500/50">${hash.substring(0, 7)}</a>`;
- });
-
- // Auto-link users (@user)
- processed = processed.replace(/@([a-zA-Z0-9-]+)/g, '<a href="https://github.com/$1" target="_blank" class="text-zinc-300 hover:text-white hover:underline font-medium">@$1</a>');
-
- return processed.split('\n').map(line => {
- line = line.trim();
-
- // Formatting helper
- const formatLine = (text: string) => text
- .replace(/\*\*(.*?)\*\*/g, '<strong class="text-zinc-200">$1</strong>')
- .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em class="text-zinc-400 italic">$1</em>')
- .replace(/`([^`]+)`/g, '<code class="bg-zinc-800 px-1 py-0.5 rounded text-xs text-zinc-300 font-mono border border-white/5 break-all whitespace-normal">$1</code>')
- .replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" class="text-indigo-400 hover:text-indigo-300 hover:underline decoration-indigo-400/30 break-all">$1</a>');
-
- // Lists
- if (line.startsWith('- ') || line.startsWith('* ')) {
- return `<li class="ml-4 list-disc marker:text-zinc-600 mb-1 pl-1 text-zinc-400">${formatLine(line.substring(2))}</li>`;
- }
-
- // Headers
- if (line.startsWith('##')) {
- return `<h3 class="text-sm font-bold mt-6 mb-3 text-zinc-100 flex items-center gap-2 border-b border-white/5 pb-2 uppercase tracking-wide">${line.replace(/^#+\s+/, '')}</h3>`;
- }
- if (line.startsWith('#')) {
- return `<h3 class="text-base font-bold mt-6 mb-3 text-white">${line.replace(/^#+\s+/, '')}</h3>`;
- }
-
- // Blockquotes
- if (line.startsWith('> ')) {
- return `<blockquote class="border-l-2 border-zinc-700 pl-4 py-1 my-2 text-zinc-500 italic bg-white/5 rounded-r-sm">${formatLine(line.substring(2))}</blockquote>`;
- }
-
- // Empty lines
- if (line === '') return '<div class="h-2"></div>';
-
- // Paragraphs
- return `<p class="mb-1.5 leading-relaxed">${formatLine(line)}</p>`;
- }).join('');
- }
-</script>
-
-<div class="absolute inset-0 z-0 overflow-hidden pointer-events-none">
- <!-- Fixed Background -->
- <div class="absolute inset-0 bg-gradient-to-t from-[#09090b] via-[#09090b]/60 to-transparent"></div>
-</div>
-
-<!-- Scrollable Container -->
-<div class="relative z-10 h-full {releasesState.isLoading ? 'overflow-hidden' : 'overflow-y-auto custom-scrollbar scroll-smooth'}">
-
- <!-- Hero Section (Full Height) - Interactive area for Saturn rotation -->
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="min-h-full flex flex-col justify-end p-12 pb-32 cursor-grab active:cursor-grabbing select-none"
- onmousedown={handleSaturnMouseDown}
- onmousemove={handleSaturnMouseMove}
- onmouseup={handleSaturnMouseUp}
- onmouseleave={handleSaturnMouseLeave}
- ontouchstart={handleSaturnTouchStart}
- ontouchmove={handleSaturnTouchMove}
- ontouchend={handleSaturnTouchEnd}
- >
- <!-- 3D Floating Hero Text -->
- <div
- class="transition-transform duration-200 ease-out origin-bottom-left"
- style:transform={`perspective(1000px) rotateX(${mouseY * -1}deg) rotateY(${mouseX * 1}deg)`}
- >
- <div class="flex items-center gap-3 mb-6">
- <div class="h-px w-12 bg-white/50"></div>
- <span class="text-xs font-mono font-bold tracking-[0.2em] text-white/50 uppercase">Launcher Active</span>
- </div>
-
- <h1
- class="text-8xl font-black tracking-tighter text-white mb-6 leading-none"
- >
- MINECRAFT
- </h1>
-
- <div class="flex items-center gap-4">
- <div
- class="bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-sm text-xs font-bold uppercase tracking-widest text-white shadow-sm"
- >
- Java Edition
- </div>
- <div class="h-4 w-px bg-white/20"></div>
- <div class="text-xl font-light text-zinc-400">
- Latest Release <span class="text-white font-medium">{gameState.latestRelease?.id || '...'}</span>
- </div>
- </div>
- </div>
-
- <!-- Action Area -->
- <div class="mt-8 flex gap-4">
- <div class="text-zinc-500 text-sm font-mono">
- > Ready to launch session.
- </div>
- </div>
-
- <!-- Scroll Hint -->
- {#if !releasesState.isLoading && releasesState.releases.length > 0}
- <div class="absolute bottom-10 left-12 animate-bounce text-zinc-600 flex flex-col items-center gap-2 w-fit opacity-50 hover:opacity-100 transition-opacity">
- <span class="text-[10px] font-mono uppercase tracking-widest">Scroll for Updates</span>
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg>
- </div>
- {/if}
- </div>
-
- <!-- Changelog / Updates Section -->
- <div class="bg-[#09090b] relative z-20 px-12 pb-24 pt-12 border-t border-white/5 min-h-[50vh]">
- <div class="max-w-4xl">
- <h2 class="text-2xl font-bold text-white mb-10 flex items-center gap-3">
- <span class="w-1.5 h-8 bg-emerald-500 rounded-sm"></span>
- LATEST UPDATES
- </h2>
-
- {#if releasesState.isLoading}
- <div class="flex flex-col gap-8">
- {#each Array(3) as _}
- <div class="h-48 bg-white/5 rounded-sm animate-pulse border border-white/5"></div>
- {/each}
- </div>
- {:else if releasesState.error}
- <div class="p-6 border border-red-500/20 bg-red-500/10 text-red-400 rounded-sm">
- Failed to load updates: {releasesState.error}
- </div>
- {:else if releasesState.releases.length === 0}
- <div class="text-zinc-500 italic">No releases found.</div>
- {:else}
- <div class="space-y-12">
- {#each releasesState.releases as release}
- <div class="group relative pl-8 border-l border-white/10 pb-4 last:pb-0 last:border-l-0">
- <!-- Timeline Dot -->
- <div class="absolute -left-[5px] top-1.5 w-2.5 h-2.5 rounded-full bg-zinc-800 border border-zinc-600 group-hover:bg-emerald-500 group-hover:border-emerald-400 transition-colors"></div>
-
- <div class="flex items-baseline gap-4 mb-3">
- <h3 class="text-xl font-bold text-white group-hover:text-emerald-400 transition-colors">
- {release.name || release.tag_name}
- </h3>
- <div class="text-xs font-mono text-zinc-500 flex items-center gap-2">
- <Calendar size={12} />
- {formatDate(release.published_at)}
- </div>
- </div>
-
- <div class="bg-zinc-900/50 border border-white/5 hover:border-white/10 rounded-sm p-6 text-zinc-400 text-sm leading-relaxed transition-colors overflow-hidden">
- <div class="prose prose-invert prose-sm max-w-none prose-p:text-zinc-400 prose-headings:text-zinc-200 prose-ul:my-2 prose-li:my-0 break-words whitespace-normal">
- {@html formatBody(release.body)}
- </div>
- </div>
-
- <a href={release.html_url} target="_blank" class="inline-flex items-center gap-2 mt-3 text-[10px] font-bold uppercase tracking-wider text-zinc-600 hover:text-white transition-colors">
- View full changelog on GitHub <ExternalLink size={10} />
- </a>
- </div>
- {/each}
- </div>
- {/if}
- </div>
- </div>
-</div>
diff --git a/packages/ui/src/components/InstanceCreationModal.svelte b/packages/ui/src/components/InstanceCreationModal.svelte
deleted file mode 100644
index c54cb98..0000000
--- a/packages/ui/src/components/InstanceCreationModal.svelte
+++ /dev/null
@@ -1,485 +0,0 @@
-<script lang="ts">
- import { invoke } from "@tauri-apps/api/core";
- import { X, ChevronLeft, ChevronRight, Loader2, Search } from "lucide-svelte";
- import { instancesState } from "../stores/instances.svelte";
- import { gameState } from "../stores/game.svelte";
- import type { Version, Instance, FabricLoaderEntry, ForgeVersion } from "../types";
-
- interface Props {
- isOpen: boolean;
- onClose: () => void;
- }
-
- let { isOpen, onClose }: Props = $props();
-
- // Wizard steps: 1 = Name, 2 = Version, 3 = Mod Loader
- let currentStep = $state(1);
- let instanceName = $state("");
- let selectedVersion = $state<Version | null>(null);
- let modLoaderType = $state<"vanilla" | "fabric" | "forge">("vanilla");
- let selectedFabricLoader = $state("");
- let selectedForgeLoader = $state("");
- let creating = $state(false);
- let errorMessage = $state("");
-
- // Mod loader lists
- let fabricLoaders = $state<FabricLoaderEntry[]>([]);
- let forgeVersions = $state<ForgeVersion[]>([]);
- let loadingLoaders = $state(false);
-
- // Version list filtering
- let versionSearch = $state("");
- let versionFilter = $state<"all" | "release" | "snapshot">("release");
-
- // Filtered versions
- let filteredVersions = $derived(() => {
- let versions = gameState.versions || [];
-
- // Filter by type
- if (versionFilter !== "all") {
- versions = versions.filter((v) => v.type === versionFilter);
- }
-
- // Search filter
- if (versionSearch) {
- versions = versions.filter((v) =>
- v.id.toLowerCase().includes(versionSearch.toLowerCase())
- );
- }
-
- return versions;
- });
-
- // Fetch mod loaders when entering step 3
- async function loadModLoaders() {
- if (!selectedVersion) return;
-
- loadingLoaders = true;
- try {
- if (modLoaderType === "fabric") {
- const loaders = await invoke<FabricLoaderEntry[]>("get_fabric_loaders_for_version", {
- gameVersion: selectedVersion.id,
- });
- fabricLoaders = loaders;
- if (loaders.length > 0) {
- selectedFabricLoader = loaders[0].loader.version;
- }
- } else if (modLoaderType === "forge") {
- const versions = await invoke<ForgeVersion[]>("get_forge_versions_for_game", {
- gameVersion: selectedVersion.id,
- });
- forgeVersions = versions;
- if (versions.length > 0) {
- selectedForgeLoader = versions[0].version;
- }
- }
- } catch (err) {
- errorMessage = `Failed to load ${modLoaderType} versions: ${err}`;
- } finally {
- loadingLoaders = false;
- }
- }
-
- // Watch for mod loader type changes and load loaders
- $effect(() => {
- if (currentStep === 3 && modLoaderType !== "vanilla") {
- loadModLoaders();
- }
- });
-
- // Reset modal state
- function resetModal() {
- currentStep = 1;
- instanceName = "";
- selectedVersion = null;
- modLoaderType = "vanilla";
- selectedFabricLoader = "";
- selectedForgeLoader = "";
- creating = false;
- errorMessage = "";
- versionSearch = "";
- versionFilter = "release";
- }
-
- function handleClose() {
- if (!creating) {
- resetModal();
- onClose();
- }
- }
-
- function goToStep(step: number) {
- errorMessage = "";
- currentStep = step;
- }
-
- function validateStep1() {
- if (!instanceName.trim()) {
- errorMessage = "Please enter an instance name";
- return false;
- }
- return true;
- }
-
- function validateStep2() {
- if (!selectedVersion) {
- errorMessage = "Please select a Minecraft version";
- return false;
- }
- return true;
- }
-
- async function handleNext() {
- errorMessage = "";
-
- if (currentStep === 1) {
- if (validateStep1()) {
- goToStep(2);
- }
- } else if (currentStep === 2) {
- if (validateStep2()) {
- goToStep(3);
- }
- }
- }
-
- async function handleCreate() {
- if (!validateStep1() || !validateStep2()) return;
-
- creating = true;
- errorMessage = "";
-
- try {
- // Step 1: Create instance
- const instance: Instance = await invoke("create_instance", {
- name: instanceName.trim(),
- });
-
- // Step 2: Install vanilla version
- await invoke("install_version", {
- instanceId: instance.id,
- versionId: selectedVersion!.id,
- });
-
- // Step 3: Install mod loader if selected
- if (modLoaderType === "fabric" && selectedFabricLoader) {
- await invoke("install_fabric", {
- instanceId: instance.id,
- gameVersion: selectedVersion!.id,
- loaderVersion: selectedFabricLoader,
- });
- } else if (modLoaderType === "forge" && selectedForgeLoader) {
- await invoke("install_forge", {
- instanceId: instance.id,
- gameVersion: selectedVersion!.id,
- forgeVersion: selectedForgeLoader,
- });
- } else {
- // Update instance with vanilla version_id
- await invoke("update_instance", {
- instance: { ...instance, version_id: selectedVersion!.id },
- });
- }
-
- // Reload instances
- await instancesState.loadInstances();
-
- // Success! Close modal
- resetModal();
- onClose();
- } catch (error) {
- errorMessage = String(error);
- creating = false;
- }
- }
-</script>
-
-{#if isOpen}
- <div
- class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
- role="dialog"
- aria-modal="true"
- >
- <div
- class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col"
- >
- <!-- Header -->
- <div
- class="flex items-center justify-between p-6 border-b border-zinc-700"
- >
- <div>
- <h2 class="text-xl font-bold text-white">Create New Instance</h2>
- <p class="text-sm text-zinc-400 mt-1">
- Step {currentStep} of 3
- </p>
- </div>
- <button
- onclick={handleClose}
- disabled={creating}
- class="p-2 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors disabled:opacity-50"
- >
- <X size={20} />
- </button>
- </div>
-
- <!-- Progress indicator -->
- <div class="flex gap-2 px-6 pt-4">
- <div
- class="flex-1 h-1 rounded-full transition-colors {currentStep >= 1
- ? 'bg-indigo-500'
- : 'bg-zinc-700'}"
- ></div>
- <div
- class="flex-1 h-1 rounded-full transition-colors {currentStep >= 2
- ? 'bg-indigo-500'
- : 'bg-zinc-700'}"
- ></div>
- <div
- class="flex-1 h-1 rounded-full transition-colors {currentStep >= 3
- ? 'bg-indigo-500'
- : 'bg-zinc-700'}"
- ></div>
- </div>
-
- <!-- Content -->
- <div class="flex-1 overflow-y-auto p-6">
- {#if currentStep === 1}
- <!-- Step 1: Name -->
- <div class="space-y-4">
- <div>
- <label
- for="instance-name"
- class="block text-sm font-medium text-white/90 mb-2"
- >Instance Name</label
- >
- <input
- id="instance-name"
- type="text"
- bind:value={instanceName}
- placeholder="My Minecraft Instance"
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
- disabled={creating}
- />
- </div>
- <p class="text-xs text-zinc-400">
- Give your instance a memorable name
- </p>
- </div>
- {:else if currentStep === 2}
- <!-- Step 2: Version Selection -->
- <div class="space-y-4">
- <div class="flex gap-4">
- <div class="flex-1 relative">
- <Search
- size={16}
- class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
- />
- <input
- type="text"
- bind:value={versionSearch}
- placeholder="Search versions..."
- class="w-full pl-10 pr-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
- />
- </div>
- <div class="flex gap-2">
- {#each [
- { value: "all", label: "All" },
- { value: "release", label: "Release" },
- { value: "snapshot", label: "Snapshot" },
- ] as filter}
- <button
- onclick={() => {
- versionFilter = filter.value as "all" | "release" | "snapshot";
- }}
- class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {versionFilter ===
- filter.value
- ? 'bg-indigo-600 text-white'
- : 'bg-zinc-800 text-zinc-400 hover:text-white'}"
- >
- {filter.label}
- </button>
- {/each}
- </div>
- </div>
-
- <div class="max-h-96 overflow-y-auto space-y-2">
- {#each filteredVersions() as version}
- <button
- onclick={() => (selectedVersion = version)}
- class="w-full p-3 rounded-lg border transition-colors text-left {selectedVersion?.id ===
- version.id
- ? 'bg-indigo-600/20 border-indigo-500 text-white'
- : 'bg-zinc-800 border-zinc-700 text-zinc-300 hover:border-zinc-600'}"
- >
- <div class="flex items-center justify-between">
- <span class="font-medium">{version.id}</span>
- <span
- class="text-xs px-2 py-1 rounded-full {version.type ===
- 'release'
- ? 'bg-green-500/20 text-green-400'
- : 'bg-yellow-500/20 text-yellow-400'}"
- >
- {version.type}
- </span>
- </div>
- </button>
- {/each}
-
- {#if filteredVersions().length === 0}
- <div class="text-center py-8 text-zinc-500">
- No versions found
- </div>
- {/if}
- </div>
- </div>
- {:else if currentStep === 3}
- <!-- Step 3: Mod Loader -->
- <div class="space-y-4">
- <div>
- <div class="text-sm font-medium text-white/90 mb-3">
- Mod Loader Type
- </div>
- <div class="flex gap-3">
- {#each [
- { value: "vanilla", label: "Vanilla" },
- { value: "fabric", label: "Fabric" },
- { value: "forge", label: "Forge" },
- ] as loader}
- <button
- onclick={() => {
- modLoaderType = loader.value as "vanilla" | "fabric" | "forge";
- }}
- class="flex-1 px-4 py-3 rounded-lg text-sm font-medium transition-colors {modLoaderType ===
- loader.value
- ? 'bg-indigo-600 text-white'
- : 'bg-zinc-800 text-zinc-400 hover:text-white'}"
- >
- {loader.label}
- </button>
- {/each}
- </div>
- </div>
-
- {#if modLoaderType === "fabric"}
- <div>
- <label for="fabric-loader" class="block text-sm font-medium text-white/90 mb-2">
- Fabric Loader Version
- </label>
- {#if loadingLoaders}
- <div class="flex items-center gap-2 text-zinc-400">
- <Loader2 size={16} class="animate-spin" />
- Loading Fabric versions...
- </div>
- {:else if fabricLoaders.length > 0}
- <select
- id="fabric-loader"
- bind:value={selectedFabricLoader}
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
- >
- {#each fabricLoaders as loader}
- <option value={loader.loader.version}>
- {loader.loader.version} {loader.loader.stable ? "(Stable)" : "(Beta)"}
- </option>
- {/each}
- </select>
- {:else}
- <p class="text-sm text-red-400">No Fabric loaders available for this version</p>
- {/if}
- </div>
- {:else if modLoaderType === "forge"}
- <div>
- <label for="forge-version" class="block text-sm font-medium text-white/90 mb-2">
- Forge Version
- </label>
- {#if loadingLoaders}
- <div class="flex items-center gap-2 text-zinc-400">
- <Loader2 size={16} class="animate-spin" />
- Loading Forge versions...
- </div>
- {:else if forgeVersions.length > 0}
- <select
- id="forge-version"
- bind:value={selectedForgeLoader}
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
- >
- {#each forgeVersions as version}
- <option value={version.version}>
- {version.version}
- </option>
- {/each}
- </select>
- {:else}
- <p class="text-sm text-red-400">No Forge versions available for this version</p>
- {/if}
- </div>
- {:else if modLoaderType === "vanilla"}
- <p class="text-sm text-zinc-400">
- Create a vanilla Minecraft instance without any mod loaders
- </p>
- {/if}
- </div>
- {/if}
-
- {#if errorMessage}
- <div
- class="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm"
- >
- {errorMessage}
- </div>
- {/if}
- </div>
-
- <!-- Footer -->
- <div
- class="flex items-center justify-between gap-3 p-6 border-t border-zinc-700"
- >
- <button
- onclick={() => goToStep(currentStep - 1)}
- disabled={currentStep === 1 || creating}
- class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
- >
- <ChevronLeft size={16} />
- Back
- </button>
-
- <div class="flex gap-3">
- <button
- onclick={handleClose}
- disabled={creating}
- class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white transition-colors disabled:opacity-50"
- >
- Cancel
- </button>
-
- {#if currentStep < 3}
- <button
- onclick={handleNext}
- disabled={creating}
- class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
- >
- Next
- <ChevronRight size={16} />
- </button>
- {:else}
- <button
- onclick={handleCreate}
- disabled={creating ||
- !instanceName.trim() ||
- !selectedVersion ||
- (modLoaderType === "fabric" && !selectedFabricLoader) ||
- (modLoaderType === "forge" && !selectedForgeLoader)}
- class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
- >
- {#if creating}
- <Loader2 size={16} class="animate-spin" />
- Creating...
- {:else}
- Create Instance
- {/if}
- </button>
- {/if}
- </div>
- </div>
- </div>
- </div>
-{/if}
diff --git a/packages/ui/src/components/InstanceEditorModal.svelte b/packages/ui/src/components/InstanceEditorModal.svelte
deleted file mode 100644
index 0856d93..0000000
--- a/packages/ui/src/components/InstanceEditorModal.svelte
+++ /dev/null
@@ -1,439 +0,0 @@
-<script lang="ts">
- import { invoke } from "@tauri-apps/api/core";
- import { X, Save, Loader2, Trash2, FolderOpen } from "lucide-svelte";
- import { instancesState } from "../stores/instances.svelte";
- import { gameState } from "../stores/game.svelte";
- import { settingsState } from "../stores/settings.svelte";
- import type { Instance, FileInfo, FabricLoaderEntry, ForgeVersion } from "../types";
- import ModLoaderSelector from "./ModLoaderSelector.svelte";
-
- interface Props {
- isOpen: boolean;
- instance: Instance | null;
- onClose: () => void;
- }
-
- let { isOpen, instance, onClose }: Props = $props();
-
- // Tabs: "info" | "version" | "files" | "settings"
- let activeTab = $state<"info" | "version" | "files" | "settings">("info");
- let saving = $state(false);
- let errorMessage = $state("");
-
- // Info tab state
- let editName = $state("");
- let editNotes = $state("");
-
- // Version tab state
- let fabricLoaders = $state<FabricLoaderEntry[]>([]);
- let forgeVersions = $state<ForgeVersion[]>([]);
- let loadingVersions = $state(false);
-
- // Files tab state
- let selectedFileFolder = $state<"mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots">("mods");
- let fileList = $state<FileInfo[]>([]);
- let loadingFiles = $state(false);
- let deletingPath = $state<string | null>(null);
-
- // Settings tab state
- let editMemoryMin = $state(0);
- let editMemoryMax = $state(0);
- let editJavaArgs = $state("");
-
- // Initialize form when instance changes
- $effect(() => {
- if (isOpen && instance) {
- editName = instance.name;
- editNotes = instance.notes || "";
- editMemoryMin = instance.memory_override?.min || settingsState.settings.min_memory || 512;
- editMemoryMax = instance.memory_override?.max || settingsState.settings.max_memory || 2048;
- editJavaArgs = instance.jvm_args_override || "";
- errorMessage = "";
- }
- });
-
- // Load files when switching to files tab
- $effect(() => {
- if (isOpen && instance && activeTab === "files") {
- loadFileList();
- }
- });
-
- // Load file list for selected folder
- async function loadFileList() {
- if (!instance) return;
-
- loadingFiles = true;
- try {
- const files = await invoke<FileInfo[]>("list_instance_directory", {
- instanceId: instance.id,
- folder: selectedFileFolder,
- });
- fileList = files;
- } catch (err) {
- errorMessage = `Failed to load files: ${err}`;
- fileList = [];
- } finally {
- loadingFiles = false;
- }
- }
-
- // Change selected folder and reload
- async function changeFolder(folder: "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots") {
- selectedFileFolder = folder;
- await loadFileList();
- }
-
- // Delete a file or directory
- async function deleteFile(filePath: string) {
- if (!confirm(`Are you sure you want to delete "${filePath.split("/").pop()}"?`)) {
- return;
- }
-
- deletingPath = filePath;
- try {
- await invoke("delete_instance_file", { path: filePath });
- // Reload file list
- await loadFileList();
- } catch (err) {
- errorMessage = `Failed to delete file: ${err}`;
- } finally {
- deletingPath = null;
- }
- }
-
- // Open file in system explorer
- async function openInExplorer(filePath: string) {
- try {
- await invoke("open_file_explorer", { path: filePath });
- } catch (err) {
- errorMessage = `Failed to open file explorer: ${err}`;
- }
- }
-
- // Save instance changes
- async function saveChanges() {
- if (!instance) return;
- if (!editName.trim()) {
- errorMessage = "Instance name cannot be empty";
- return;
- }
-
- saving = true;
- errorMessage = "";
-
- try {
- const updatedInstance: Instance = {
- ...instance,
- name: editName.trim(),
- notes: editNotes.trim() || undefined,
- memory_override: {
- min: editMemoryMin,
- max: editMemoryMax,
- },
- jvm_args_override: editJavaArgs.trim() || undefined,
- };
-
- await instancesState.updateInstance(updatedInstance);
- onClose();
- } catch (err) {
- errorMessage = `Failed to save instance: ${err}`;
- } finally {
- saving = false;
- }
- }
-
- function formatFileSize(bytes: number): string {
- if (bytes === 0) return "0 B";
- const k = 1024;
- const sizes = ["B", "KB", "MB", "GB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
- }
-
- function formatDate(timestamp: number): string {
- return new Date(timestamp * 1000).toLocaleDateString();
- }
-</script>
-
-{#if isOpen && instance}
- <div
- class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
- role="dialog"
- aria-modal="true"
- >
- <div
- class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"
- >
- <!-- Header -->
- <div class="flex items-center justify-between p-6 border-b border-zinc-700">
- <div>
- <h2 class="text-xl font-bold text-white">Edit Instance</h2>
- <p class="text-sm text-zinc-400 mt-1">{instance.name}</p>
- </div>
- <button
- onclick={onClose}
- disabled={saving}
- class="p-2 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors disabled:opacity-50"
- >
- <X size={20} />
- </button>
- </div>
-
- <!-- Tab Navigation -->
- <div class="flex gap-1 px-6 pt-4 border-b border-zinc-700">
- {#each [
- { id: "info", label: "Info" },
- { id: "version", label: "Version" },
- { id: "files", label: "Files" },
- { id: "settings", label: "Settings" },
- ] as tab}
- <button
- onclick={() => (activeTab = tab.id as any)}
- class="px-4 py-2 text-sm font-medium transition-colors rounded-t-lg {activeTab === tab.id
- ? 'bg-indigo-600 text-white'
- : 'bg-zinc-800 text-zinc-400 hover:text-white'}"
- >
- {tab.label}
- </button>
- {/each}
- </div>
-
- <!-- Content Area -->
- <div class="flex-1 overflow-y-auto p-6">
- {#if activeTab === "info"}
- <!-- Info Tab -->
- <div class="space-y-4">
- <div>
- <label for="instance-name" class="block text-sm font-medium text-white/90 mb-2">
- Instance Name
- </label>
- <input
- id="instance-name"
- type="text"
- bind:value={editName}
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
- disabled={saving}
- />
- </div>
-
- <div>
- <label for="instance-notes" class="block text-sm font-medium text-white/90 mb-2">
- Notes
- </label>
- <textarea
- id="instance-notes"
- bind:value={editNotes}
- rows="4"
- placeholder="Add notes about this instance..."
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none"
- disabled={saving}
- ></textarea>
- </div>
-
- <div class="grid grid-cols-2 gap-4 text-sm">
- <div class="p-3 bg-zinc-800 rounded-lg">
- <p class="text-zinc-400">Created</p>
- <p class="text-white font-medium">{formatDate(instance.created_at)}</p>
- </div>
- <div class="p-3 bg-zinc-800 rounded-lg">
- <p class="text-zinc-400">Last Played</p>
- <p class="text-white font-medium">
- {instance.last_played ? formatDate(instance.last_played) : "Never"}
- </p>
- </div>
- <div class="p-3 bg-zinc-800 rounded-lg">
- <p class="text-zinc-400">Game Directory</p>
- <p class="text-white font-medium text-xs truncate" title={instance.game_dir}>
- {instance.game_dir.split("/").pop()}
- </p>
- </div>
- <div class="p-3 bg-zinc-800 rounded-lg">
- <p class="text-zinc-400">Current Version</p>
- <p class="text-white font-medium">{instance.version_id || "None"}</p>
- </div>
- </div>
- </div>
- {:else if activeTab === "version"}
- <!-- Version Tab -->
- <div class="space-y-4">
- {#if instance.version_id}
- <div class="p-4 bg-indigo-500/10 border border-indigo-500/30 rounded-lg">
- <p class="text-sm text-indigo-400">
- Currently playing: <span class="font-medium">{instance.version_id}</span>
- {#if instance.mod_loader}
- with <span class="capitalize">{instance.mod_loader}</span>
- {instance.mod_loader_version && `${instance.mod_loader_version}`}
- {/if}
- </p>
- </div>
- {/if}
-
- <div>
- <h3 class="text-sm font-medium text-white/90 mb-4">Change Version or Mod Loader</h3>
- <ModLoaderSelector
- selectedGameVersion={instance.version_id || ""}
- onInstall={(versionId) => {
- // Version installed, update instance version_id
- instance.version_id = versionId;
- saveChanges();
- }}
- />
- </div>
- </div>
- {:else if activeTab === "files"}
- <!-- Files Tab -->
- <div class="space-y-4">
- <div class="flex gap-2 flex-wrap">
- {#each ["mods", "resourcepacks", "shaderpacks", "saves", "screenshots"] as folder}
- <button
- onclick={() => changeFolder(folder as any)}
- class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors {selectedFileFolder ===
- folder
- ? "bg-indigo-600 text-white"
- : "bg-zinc-800 text-zinc-400 hover:text-white"}"
- >
- {folder}
- </button>
- {/each}
- </div>
-
- {#if loadingFiles}
- <div class="flex items-center gap-2 text-zinc-400 py-8 justify-center">
- <Loader2 size={16} class="animate-spin" />
- Loading files...
- </div>
- {:else if fileList.length === 0}
- <div class="text-center py-8 text-zinc-500">
- No files in this folder
- </div>
- {:else}
- <div class="space-y-2">
- {#each fileList as file}
- <div
- class="flex items-center justify-between p-3 bg-zinc-800 rounded-lg hover:bg-zinc-700 transition-colors"
- >
- <div class="flex-1 min-w-0">
- <p class="font-medium text-white truncate">{file.name}</p>
- <p class="text-xs text-zinc-400">
- {file.is_directory ? "Folder" : formatFileSize(file.size)}
- • {formatDate(file.modified)}
- </p>
- </div>
- <div class="flex gap-2 ml-4">
- <button
- onclick={() => openInExplorer(file.path)}
- title="Open in explorer"
- class="p-2 rounded-lg hover:bg-zinc-600 text-zinc-400 hover:text-white transition-colors"
- >
- <FolderOpen size={16} />
- </button>
- <button
- onclick={() => deleteFile(file.path)}
- disabled={deletingPath === file.path}
- title="Delete"
- class="p-2 rounded-lg hover:bg-red-600/20 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
- >
- {#if deletingPath === file.path}
- <Loader2 size={16} class="animate-spin" />
- {:else}
- <Trash2 size={16} />
- {/if}
- </button>
- </div>
- </div>
- {/each}
- </div>
- {/if}
- </div>
- {:else if activeTab === "settings"}
- <!-- Settings Tab -->
- <div class="space-y-4">
- <div>
- <label for="min-memory" class="block text-sm font-medium text-white/90 mb-2">
- Minimum Memory (MB)
- </label>
- <input
- id="min-memory"
- type="number"
- bind:value={editMemoryMin}
- min="256"
- max={editMemoryMax}
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
- disabled={saving}
- />
- <p class="text-xs text-zinc-400 mt-1">
- Default: {settingsState.settings.min_memory}MB
- </p>
- </div>
-
- <div>
- <label for="max-memory" class="block text-sm font-medium text-white/90 mb-2">
- Maximum Memory (MB)
- </label>
- <input
- id="max-memory"
- type="number"
- bind:value={editMemoryMax}
- min={editMemoryMin}
- max="16384"
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
- disabled={saving}
- />
- <p class="text-xs text-zinc-400 mt-1">
- Default: {settingsState.settings.max_memory}MB
- </p>
- </div>
-
- <div>
- <label for="java-args" class="block text-sm font-medium text-white/90 mb-2">
- JVM Arguments (Advanced)
- </label>
- <textarea
- id="java-args"
- bind:value={editJavaArgs}
- rows="4"
- placeholder="-XX:+UnlockExperimentalVMOptions -XX:G1NewCollectionPercentage=20..."
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 font-mono text-sm resize-none"
- disabled={saving}
- ></textarea>
- <p class="text-xs text-zinc-400 mt-1">
- Leave empty to use global Java arguments
- </p>
- </div>
- </div>
- {/if}
-
- {#if errorMessage}
- <div class="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
- {errorMessage}
- </div>
- {/if}
- </div>
-
- <!-- Footer -->
- <div class="flex items-center justify-end gap-3 p-6 border-t border-zinc-700">
- <button
- onclick={onClose}
- disabled={saving}
- class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white transition-colors disabled:opacity-50"
- >
- Cancel
- </button>
- <button
- onclick={saveChanges}
- disabled={saving || !editName.trim()}
- class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
- >
- {#if saving}
- <Loader2 size={16} class="animate-spin" />
- Saving...
- {:else}
- <Save size={16} />
- Save Changes
- {/if}
- </button>
- </div>
- </div>
- </div>
-{/if}
diff --git a/packages/ui/src/components/InstancesView.svelte b/packages/ui/src/components/InstancesView.svelte
deleted file mode 100644
index 5334f9e..0000000
--- a/packages/ui/src/components/InstancesView.svelte
+++ /dev/null
@@ -1,259 +0,0 @@
-<script lang="ts">
- import { onMount } from "svelte";
- import { instancesState } from "../stores/instances.svelte";
- import { Plus, Trash2, Edit2, Copy, Check, X } from "lucide-svelte";
- import type { Instance } from "../types";
- import InstanceCreationModal from "./InstanceCreationModal.svelte";
- import InstanceEditorModal from "./InstanceEditorModal.svelte";
-
- let showCreateModal = $state(false);
- let showEditModal = $state(false);
- let showDeleteConfirm = $state(false);
- let showDuplicateModal = $state(false);
- let selectedInstance: Instance | null = $state(null);
- let editingInstance: Instance | null = $state(null);
- let newInstanceName = $state("");
- let duplicateName = $state("");
-
- onMount(() => {
- instancesState.loadInstances();
- });
-
- function handleCreate() {
- showCreateModal = true;
- }
-
- function handleEdit(instance: Instance) {
- editingInstance = instance;
- }
-
- function handleDelete(instance: Instance) {
- selectedInstance = instance;
- showDeleteConfirm = true;
- }
-
- function handleDuplicate(instance: Instance) {
- selectedInstance = instance;
- duplicateName = `${instance.name} (Copy)`;
- showDuplicateModal = true;
- }
-
- async function confirmDelete() {
- if (!selectedInstance) return;
- await instancesState.deleteInstance(selectedInstance.id);
- showDeleteConfirm = false;
- selectedInstance = null;
- }
-
- async function confirmDuplicate() {
- if (!selectedInstance || !duplicateName.trim()) return;
- await instancesState.duplicateInstance(selectedInstance.id, duplicateName.trim());
- showDuplicateModal = false;
- selectedInstance = null;
- duplicateName = "";
- }
-
- function formatDate(timestamp: number): string {
- return new Date(timestamp * 1000).toLocaleDateString();
- }
-
- function formatLastPlayed(timestamp: number): string {
- const date = new Date(timestamp * 1000);
- const now = new Date();
- const diff = now.getTime() - date.getTime();
- const days = Math.floor(diff / (1000 * 60 * 60 * 24));
-
- if (days === 0) return "Today";
- if (days === 1) return "Yesterday";
- if (days < 7) return `${days} days ago`;
- return date.toLocaleDateString();
- }
-</script>
-
-<div class="h-full flex flex-col gap-4 p-6 overflow-y-auto">
- <div class="flex items-center justify-between">
- <h1 class="text-2xl font-bold text-gray-900 dark:text-white">Instances</h1>
- <button
- onclick={handleCreate}
- class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
- >
- <Plus size={18} />
- Create Instance
- </button>
- </div>
-
- {#if instancesState.instances.length === 0}
- <div class="flex-1 flex items-center justify-center">
- <div class="text-center text-gray-500 dark:text-gray-400">
- <p class="text-lg mb-2">No instances yet</p>
- <p class="text-sm">Create your first instance to get started</p>
- </div>
- </div>
- {:else}
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- {#each instancesState.instances as instance (instance.id)}
- <div
- role="button"
- tabindex="0"
- class="relative p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 transition-all cursor-pointer hover:border-blue-500 {instancesState.activeInstanceId === instance.id
- ? 'border-blue-500'
- : 'border-transparent'}"
- onclick={() => instancesState.setActiveInstance(instance.id)}
- onkeydown={(e) => e.key === "Enter" && instancesState.setActiveInstance(instance.id)}
- >
- {#if instancesState.activeInstanceId === instance.id}
- <div class="absolute top-2 right-2">
- <div class="w-3 h-3 bg-blue-500 rounded-full"></div>
- </div>
- {/if}
-
- <div class="flex items-start justify-between mb-2">
- <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
- {instance.name}
- </h3>
- <div class="flex gap-1">
- <button
- type="button"
- onclick={(e) => {
- e.stopPropagation();
- handleEdit(instance);
- }}
- class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
- title="Edit"
- >
- <Edit2 size={16} class="text-gray-600 dark:text-gray-400" />
- </button>
- <button
- type="button"
- onclick={(e) => {
- e.stopPropagation();
- handleDuplicate(instance);
- }}
- class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
- title="Duplicate"
- >
- <Copy size={16} class="text-gray-600 dark:text-gray-400" />
- </button>
- <button
- type="button"
- onclick={(e) => {
- e.stopPropagation();
- handleDelete(instance);
- }}
- class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
- title="Delete"
- >
- <Trash2 size={16} class="text-red-600 dark:text-red-400" />
- </button>
- </div>
- </div>
-
- <div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
- {#if instance.version_id}
- <p>Version: <span class="font-medium">{instance.version_id}</span></p>
- {:else}
- <p class="text-gray-400">No version selected</p>
- {/if}
-
- {#if instance.mod_loader && instance.mod_loader !== "vanilla"}
- <p>
- Mod Loader: <span class="font-medium capitalize">{instance.mod_loader}</span>
- {#if instance.mod_loader_version}
- <span class="text-gray-500">({instance.mod_loader_version})</span>
- {/if}
- </p>
- {/if}
-
- <p>Created: {formatDate(instance.created_at)}</p>
-
- {#if instance.last_played}
- <p>Last played: {formatLastPlayed(instance.last_played)}</p>
- {/if}
- </div>
-
- {#if instance.notes}
- <p class="mt-2 text-sm text-gray-500 dark:text-gray-500 italic">
- {instance.notes}
- </p>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-</div>
-
-<!-- Create Modal -->
-<InstanceCreationModal isOpen={showCreateModal} onClose={() => (showCreateModal = false)} />
-
-<!-- Instance Editor Modal -->
-<InstanceEditorModal
- isOpen={editingInstance !== null}
- instance={editingInstance}
- onClose={() => {
- editingInstance = null;
- }}
-/>
-
-<!-- Delete Confirmation -->
-{#if showDeleteConfirm && selectedInstance}
- <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
- <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96">
- <h2 class="text-xl font-bold mb-4 text-red-600 dark:text-red-400">Delete Instance</h2>
- <p class="mb-4 text-gray-700 dark:text-gray-300">
- Are you sure you want to delete "{selectedInstance.name}"? This action cannot be undone and will delete all game data for this instance.
- </p>
- <div class="flex gap-2 justify-end">
- <button
- onclick={() => {
- showDeleteConfirm = false;
- selectedInstance = null;
- }}
- class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
- >
- Cancel
- </button>
- <button
- onclick={confirmDelete}
- class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
- >
- Delete
- </button>
- </div>
- </div>
- </div>
-{/if}
-
-<!-- Duplicate Modal -->
-{#if showDuplicateModal && selectedInstance}
- <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
- <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96">
- <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Duplicate Instance</h2>
- <input
- type="text"
- bind:value={duplicateName}
- placeholder="New instance name"
- class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4"
- onkeydown={(e) => e.key === "Enter" && confirmDuplicate()}
- />
- <div class="flex gap-2 justify-end">
- <button
- onclick={() => {
- showDuplicateModal = false;
- selectedInstance = null;
- duplicateName = "";
- }}
- class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
- >
- Cancel
- </button>
- <button
- onclick={confirmDuplicate}
- disabled={!duplicateName.trim()}
- class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
- >
- Duplicate
- </button>
- </div>
- </div>
- </div>
-{/if}
diff --git a/packages/ui/src/components/LoginModal.svelte b/packages/ui/src/components/LoginModal.svelte
deleted file mode 100644
index 1886cd9..0000000
--- a/packages/ui/src/components/LoginModal.svelte
+++ /dev/null
@@ -1,126 +0,0 @@
-<script lang="ts">
- import { open } from "@tauri-apps/plugin-shell";
- import { authState } from "../stores/auth.svelte";
-
- function openLink(url: string) {
- open(url);
- }
-</script>
-
-{#if authState.isLoginModalOpen}
- <div
- class="fixed inset-0 z-[60] flex items-center justify-center bg-white/80 dark:bg-black/80 backdrop-blur-sm p-4"
- >
- <div
- class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200"
- >
- <div class="flex justify-between items-center mb-6">
- <h2 class="text-2xl font-bold text-gray-900 dark:text-white">Login</h2>
- <button
- onclick={() => authState.closeLoginModal()}
- class="text-zinc-500 hover:text-black dark:hover:text-white transition group"
- >
- ✕
- </button>
- </div>
-
- {#if authState.loginMode === "select"}
- <div class="space-y-4">
- <button
- onclick={() => authState.startMicrosoftLogin()}
- class="w-full flex items-center justify-center gap-3 bg-gray-100 hover:bg-gray-200 dark:bg-[#2F2F2F] dark:hover:bg-[#3F3F3F] text-gray-900 dark:text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-400 dark:hover:border-zinc-500 transition-all group"
- >
- <!-- Microsoft Logo SVG -->
- <svg
- class="w-5 h-5"
- viewBox="0 0 23 23"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- ><path fill="#f35325" d="M1 1h10v10H1z" /><path
- fill="#81bc06"
- d="M12 1h10v10H12z"
- /><path fill="#05a6f0" d="M1 12h10v10H1z" /><path
- fill="#ffba08"
- d="M12 12h10v10H12z"
- /></svg
- >
- Microsoft Account
- </button>
-
- <div class="relative py-2">
- <div class="absolute inset-0 flex items-center">
- <div class="w-full border-t border-zinc-200 dark:border-zinc-700"></div>
- </div>
- <div class="relative flex justify-center text-xs uppercase">
- <span class="bg-white dark:bg-zinc-900 px-2 text-zinc-400 dark:text-zinc-500">OR</span>
- </div>
- </div>
-
- <div class="space-y-2">
- <input
- type="text"
- bind:value={authState.offlineUsername}
- placeholder="Offline Username"
- class="w-full bg-gray-50 border-zinc-200 dark:bg-zinc-950 dark:border-zinc-700 rounded p-3 text-gray-900 dark:text-white focus:border-indigo-500 outline-none"
- onkeydown={(e) => e.key === "Enter" && authState.performOfflineLogin()}
- />
- <button
- onclick={() => authState.performOfflineLogin()}
- class="w-full bg-gray-200 hover:bg-gray-300 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 p-3 rounded font-medium transition-colors"
- >
- Offline Login
- </button>
- </div>
- </div>
- {:else if authState.loginMode === "microsoft"}
- <div class="text-center">
- {#if authState.msLoginLoading && !authState.deviceCodeData}
- <div class="py-8 text-zinc-400 animate-pulse">
- Starting login flow...
- </div>
- {:else if authState.deviceCodeData}
- <div class="space-y-4">
- <p class="text-sm text-gray-500 dark:text-zinc-400">1. Go to this URL:</p>
- <button
- onclick={() =>
- authState.deviceCodeData && openLink(authState.deviceCodeData.verification_uri)}
- class="text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 underline break-all font-mono text-sm"
- >
- {authState.deviceCodeData.verification_uri}
- </button>
-
- <p class="text-sm text-gray-500 dark:text-zinc-400 mt-2">2. Enter this code:</p>
- <div
- class="bg-gray-50 dark:bg-zinc-950 p-4 rounded border border-zinc-200 dark:border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors dark:text-white text-gray-900"
- role="button"
- tabindex="0"
- onkeydown={(e) => e.key === 'Enter' && navigator.clipboard.writeText(authState.deviceCodeData?.user_code || "")}
- onclick={() =>
- navigator.clipboard.writeText(
- authState.deviceCodeData?.user_code || ""
- )}
- >
- {authState.deviceCodeData.user_code}
- </div>
- <p class="text-xs text-zinc-500">Click code to copy</p>
-
- <div class="pt-6 space-y-3">
- <div class="flex flex-col items-center gap-3">
- <div class="animate-spin rounded-full h-6 w-6 border-2 border-zinc-300 dark:border-zinc-600 border-t-indigo-500"></div>
- <span class="text-sm text-gray-600 dark:text-zinc-400 font-medium break-all text-center">{authState.msLoginStatus}</span>
- </div>
- <p class="text-xs text-zinc-600">This window will update automatically.</p>
- </div>
-
- <button
- onclick={() => { authState.stopPolling(); authState.loginMode = "select"; }}
- class="text-xs text-zinc-500 hover:text-zinc-300 mt-6 underline"
- >Cancel</button
- >
- </div>
- {/if}
- </div>
- {/if}
- </div>
- </div>
-{/if}
diff --git a/packages/ui/src/components/ModLoaderSelector.svelte b/packages/ui/src/components/ModLoaderSelector.svelte
deleted file mode 100644
index 50caa8c..0000000
--- a/packages/ui/src/components/ModLoaderSelector.svelte
+++ /dev/null
@@ -1,455 +0,0 @@
-<script lang="ts">
- import { invoke } from "@tauri-apps/api/core";
- import { listen, type UnlistenFn } from "@tauri-apps/api/event";
- import type {
- FabricGameVersion,
- FabricLoaderVersion,
- ForgeVersion,
- ModLoaderType,
- } from "../types";
- import { Loader2, Download, AlertCircle, Check, ChevronDown, CheckCircle } from 'lucide-svelte';
- import { logsState } from "../stores/logs.svelte";
- import { instancesState } from "../stores/instances.svelte";
-
- interface Props {
- selectedGameVersion: string;
- onInstall: (versionId: string) => void;
- }
-
- let { selectedGameVersion, onInstall }: Props = $props();
-
- // State
- let selectedLoader = $state<ModLoaderType>("vanilla");
- let isLoading = $state(false);
- let isInstalling = $state(false);
- let error = $state<string | null>(null);
- let isVersionInstalled = $state(false);
-
- // Fabric state
- let fabricLoaders = $state<FabricLoaderVersion[]>([]);
- let selectedFabricLoader = $state("");
- let isFabricDropdownOpen = $state(false);
-
- // Forge state
- let forgeVersions = $state<ForgeVersion[]>([]);
- let selectedForgeVersion = $state("");
- let isForgeDropdownOpen = $state(false);
-
- let fabricDropdownRef = $state<HTMLDivElement | null>(null);
- let forgeDropdownRef = $state<HTMLDivElement | null>(null);
-
- // Check if version is installed when game version changes
- $effect(() => {
- if (selectedGameVersion) {
- checkInstallStatus();
- }
- });
-
- // Load mod loader versions when game version or loader type changes
- $effect(() => {
- if (selectedGameVersion && selectedLoader !== "vanilla") {
- loadModLoaderVersions();
- }
- });
-
- async function checkInstallStatus() {
- if (!selectedGameVersion || !instancesState.activeInstanceId) {
- isVersionInstalled = false;
- return;
- }
- try {
- isVersionInstalled = await invoke<boolean>("check_version_installed", {
- instanceId: instancesState.activeInstanceId,
- versionId: selectedGameVersion,
- });
- } catch (e) {
- console.error("Failed to check install status:", e);
- isVersionInstalled = false;
- }
- }
-
- async function loadModLoaderVersions() {
- isLoading = true;
- error = null;
-
- try {
- if (selectedLoader === "fabric") {
- const loaders = await invoke<any[]>("get_fabric_loaders_for_version", {
- gameVersion: selectedGameVersion,
- });
- fabricLoaders = loaders.map((l) => l.loader);
- if (fabricLoaders.length > 0) {
- const stable = fabricLoaders.find((l) => l.stable);
- selectedFabricLoader = stable?.version || fabricLoaders[0].version;
- }
- } else if (selectedLoader === "forge") {
- forgeVersions = await invoke<ForgeVersion[]>(
- "get_forge_versions_for_game",
- {
- gameVersion: selectedGameVersion,
- }
- );
- if (forgeVersions.length > 0) {
- const recommended = forgeVersions.find((v) => v.recommended);
- const latest = forgeVersions.find((v) => v.latest);
- selectedForgeVersion =
- recommended?.version || latest?.version || forgeVersions[0].version;
- }
- }
- } catch (e) {
- error = `Failed to load ${selectedLoader} versions: ${e}`;
- console.error(e);
- } finally {
- isLoading = false;
- }
- }
-
- async function installVanilla() {
- if (!selectedGameVersion) {
- error = "Please select a Minecraft version first";
- return;
- }
-
- isInstalling = true;
- error = null;
- logsState.addLog("info", "Installer", `Starting installation of ${selectedGameVersion}...`);
-
- if (!instancesState.activeInstanceId) {
- error = "Please select an instance first";
- return;
- }
- try {
- await invoke("install_version", {
- instanceId: instancesState.activeInstanceId,
- versionId: selectedGameVersion,
- });
- logsState.addLog("info", "Installer", `Successfully installed ${selectedGameVersion}`);
- isVersionInstalled = true;
- onInstall(selectedGameVersion);
- } catch (e) {
- error = `Failed to install: ${e}`;
- logsState.addLog("error", "Installer", `Installation failed: ${e}`);
- console.error(e);
- } finally {
- isInstalling = false;
- }
- }
-
- async function installModLoader() {
- if (!selectedGameVersion) {
- error = "Please select a Minecraft version first";
- return;
- }
-
- if (!instancesState.activeInstanceId) {
- error = "Please select an instance first";
- isInstalling = false;
- return;
- }
-
- isInstalling = true;
- error = null;
-
- try {
- // First install the base game if not installed
- if (!isVersionInstalled) {
- logsState.addLog("info", "Installer", `Installing base game ${selectedGameVersion} first...`);
- await invoke("install_version", {
- instanceId: instancesState.activeInstanceId,
- versionId: selectedGameVersion,
- });
- isVersionInstalled = true;
- }
-
- // Then install the mod loader
- if (selectedLoader === "fabric" && selectedFabricLoader) {
- logsState.addLog("info", "Installer", `Installing Fabric ${selectedFabricLoader} for ${selectedGameVersion}...`);
- const result = await invoke<any>("install_fabric", {
- instanceId: instancesState.activeInstanceId,
- gameVersion: selectedGameVersion,
- loaderVersion: selectedFabricLoader,
- });
- logsState.addLog("info", "Installer", `Fabric installed successfully: ${result.id}`);
- onInstall(result.id);
- } else if (selectedLoader === "forge" && selectedForgeVersion) {
- logsState.addLog("info", "Installer", `Installing Forge ${selectedForgeVersion} for ${selectedGameVersion}...`);
- const result = await invoke<any>("install_forge", {
- instanceId: instancesState.activeInstanceId,
- gameVersion: selectedGameVersion,
- forgeVersion: selectedForgeVersion,
- });
- logsState.addLog("info", "Installer", `Forge installed successfully: ${result.id}`);
- onInstall(result.id);
- }
- } catch (e) {
- error = `Failed to install ${selectedLoader}: ${e}`;
- logsState.addLog("error", "Installer", `Installation failed: ${e}`);
- console.error(e);
- } finally {
- isInstalling = false;
- }
- }
-
- function onLoaderChange(loader: ModLoaderType) {
- selectedLoader = loader;
- error = null;
- if (loader !== "vanilla" && selectedGameVersion) {
- loadModLoaderVersions();
- }
- }
-
- function handleFabricClickOutside(e: MouseEvent) {
- if (fabricDropdownRef && !fabricDropdownRef.contains(e.target as Node)) {
- isFabricDropdownOpen = false;
- }
- }
-
- function handleForgeClickOutside(e: MouseEvent) {
- if (forgeDropdownRef && !forgeDropdownRef.contains(e.target as Node)) {
- isForgeDropdownOpen = false;
- }
- }
-
- $effect(() => {
- if (isFabricDropdownOpen) {
- document.addEventListener('click', handleFabricClickOutside);
- return () => document.removeEventListener('click', handleFabricClickOutside);
- }
- });
-
- $effect(() => {
- if (isForgeDropdownOpen) {
- document.addEventListener('click', handleForgeClickOutside);
- return () => document.removeEventListener('click', handleForgeClickOutside);
- }
- });
-
- let selectedFabricLabel = $derived(
- fabricLoaders.find(l => l.version === selectedFabricLoader)
- ? `${selectedFabricLoader}${fabricLoaders.find(l => l.version === selectedFabricLoader)?.stable ? ' (stable)' : ''}`
- : selectedFabricLoader || 'Select version'
- );
-
- let selectedForgeLabel = $derived(
- forgeVersions.find(v => v.version === selectedForgeVersion)
- ? `${selectedForgeVersion}${forgeVersions.find(v => v.version === selectedForgeVersion)?.recommended ? ' (Recommended)' : ''}`
- : selectedForgeVersion || 'Select version'
- );
-</script>
-
-<div class="space-y-4">
- <div class="flex items-center justify-between">
- <h3 class="text-xs font-bold uppercase tracking-widest text-zinc-500">Loader Type</h3>
- </div>
-
- <!-- Loader Type Tabs - Simple Clean Style -->
- <div class="flex p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-sm border border-zinc-200 dark:border-white/5">
- {#each ['vanilla', 'fabric', 'forge'] as loader}
- <button
- class="flex-1 px-3 py-2 rounded-sm text-sm font-medium transition-all duration-200 capitalize
- {selectedLoader === loader
- ? 'bg-white dark:bg-white/10 text-black dark:text-white shadow-sm'
- : 'text-zinc-500 dark:text-zinc-500 hover:text-black dark:hover:text-white'}"
- onclick={() => onLoaderChange(loader as ModLoaderType)}
- disabled={isInstalling}
- >
- {loader}
- </button>
- {/each}
- </div>
-
- <!-- Content Area -->
- <div class="min-h-[100px] flex flex-col justify-center">
- {#if !selectedGameVersion}
- <div class="flex items-center gap-3 p-4 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 text-amber-700 dark:text-amber-200 rounded-sm text-sm">
- <AlertCircle size={16} />
- <span>Please select a Minecraft version first.</span>
- </div>
-
- {:else if selectedLoader === "vanilla"}
- <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
- <div class="text-center p-4 border border-dashed border-zinc-200 dark:border-white/10 rounded-sm text-zinc-500 text-sm">
- Standard Minecraft experience. No modifications.
- </div>
-
- {#if isVersionInstalled}
- <div class="flex items-center justify-center gap-2 p-3 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 text-emerald-700 dark:text-emerald-300 rounded-sm text-sm">
- <CheckCircle size={16} />
- <span>Version {selectedGameVersion} is installed</span>
- </div>
- {:else}
- <button
- class="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
- onclick={installVanilla}
- disabled={isInstalling}
- >
- {#if isInstalling}
- <Loader2 class="animate-spin" size={16} />
- Installing...
- {:else}
- <Download size={16} />
- Install {selectedGameVersion}
- {/if}
- </button>
- {/if}
- </div>
-
- {:else if isLoading}
- <div class="flex flex-col items-center gap-3 text-sm text-zinc-500 py-6">
- <Loader2 class="animate-spin" size={20} />
- <span>Fetching {selectedLoader} manifest...</span>
- </div>
-
- {:else if error}
- <div class="p-4 bg-red-50 border border-red-200 text-red-700 dark:bg-red-500/10 dark:border-red-500/20 dark:text-red-300 rounded-sm text-sm break-words">
- {error}
- </div>
-
- {:else if selectedLoader === "fabric"}
- <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
- {#if fabricLoaders.length === 0}
- <div class="text-center p-4 text-sm text-zinc-500 italic">
- No Fabric versions available for {selectedGameVersion}
- </div>
- {:else}
- <div>
- <label for="fabric-loader-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2"
- >Loader Version</label
- >
- <!-- Custom Fabric Dropdown -->
- <div class="relative" bind:this={fabricDropdownRef}>
- <button
- type="button"
- onclick={() => isFabricDropdownOpen = !isFabricDropdownOpen}
- disabled={isInstalling}
- class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left
- bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md
- text-sm text-gray-900 dark:text-white
- hover:border-zinc-400 dark:hover:border-zinc-600
- focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
- transition-colors cursor-pointer outline-none disabled:opacity-50"
- >
- <span class="truncate">{selectedFabricLabel}</span>
- <ChevronDown
- size={14}
- class="shrink-0 text-zinc-400 dark:text-zinc-500 transition-transform duration-200 {isFabricDropdownOpen ? 'rotate-180' : ''}"
- />
- </button>
-
- {#if isFabricDropdownOpen}
- <div
- class="absolute z-50 w-full mt-1 py-1 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md shadow-xl
- max-h-48 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
- >
- {#each fabricLoaders as loader}
- <button
- type="button"
- onclick={() => { selectedFabricLoader = loader.version; isFabricDropdownOpen = false; }}
- class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
- transition-colors outline-none cursor-pointer
- {loader.version === selectedFabricLoader
- ? 'bg-indigo-600 text-white'
- : 'text-gray-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'}"
- >
- <span class="truncate">{loader.version} {loader.stable ? "(stable)" : ""}</span>
- {#if loader.version === selectedFabricLoader}
- <Check size={14} class="shrink-0 ml-2" />
- {/if}
- </button>
- {/each}
- </div>
- {/if}
- </div>
- </div>
-
- <button
- class="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
- onclick={installModLoader}
- disabled={isInstalling || !selectedFabricLoader}
- >
- {#if isInstalling}
- <Loader2 class="animate-spin" size={16} />
- Installing...
- {:else}
- <Download size={16} />
- Install Fabric {selectedFabricLoader}
- {/if}
- </button>
- {/if}
- </div>
-
- {:else if selectedLoader === "forge"}
- <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
- {#if forgeVersions.length === 0}
- <div class="text-center p-4 text-sm text-zinc-500 italic">
- No Forge versions available for {selectedGameVersion}
- </div>
- {:else}
- <div>
- <label for="forge-version-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2"
- >Forge Version</label
- >
- <!-- Custom Forge Dropdown -->
- <div class="relative" bind:this={forgeDropdownRef}>
- <button
- type="button"
- onclick={() => isForgeDropdownOpen = !isForgeDropdownOpen}
- disabled={isInstalling}
- class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left
- bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md
- text-sm text-gray-900 dark:text-white
- hover:border-zinc-400 dark:hover:border-zinc-600
- focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
- transition-colors cursor-pointer outline-none disabled:opacity-50"
- >
- <span class="truncate">{selectedForgeLabel}</span>
- <ChevronDown
- size={14}
- class="shrink-0 text-zinc-400 dark:text-zinc-500 transition-transform duration-200 {isForgeDropdownOpen ? 'rotate-180' : ''}"
- />
- </button>
-
- {#if isForgeDropdownOpen}
- <div
- class="absolute z-50 w-full mt-1 py-1 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md shadow-xl
- max-h-48 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
- >
- {#each forgeVersions as version}
- <button
- type="button"
- onclick={() => { selectedForgeVersion = version.version; isForgeDropdownOpen = false; }}
- class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
- transition-colors outline-none cursor-pointer
- {version.version === selectedForgeVersion
- ? 'bg-indigo-600 text-white'
- : 'text-gray-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'}"
- >
- <span class="truncate">{version.version} {version.recommended ? "(Recommended)" : ""}</span>
- {#if version.version === selectedForgeVersion}
- <Check size={14} class="shrink-0 ml-2" />
- {/if}
- </button>
- {/each}
- </div>
- {/if}
- </div>
- </div>
-
- <button
- class="w-full bg-orange-600 hover:bg-orange-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
- onclick={installModLoader}
- disabled={isInstalling || !selectedForgeVersion}
- >
- {#if isInstalling}
- <Loader2 class="animate-spin" size={16} />
- Installing...
- {:else}
- <Download size={16} />
- Install Forge {selectedForgeVersion}
- {/if}
- </button>
- {/if}
- </div>
- {/if}
- </div>
-</div>
diff --git a/packages/ui/src/components/ParticleBackground.svelte b/packages/ui/src/components/ParticleBackground.svelte
deleted file mode 100644
index 7644b1a..0000000
--- a/packages/ui/src/components/ParticleBackground.svelte
+++ /dev/null
@@ -1,70 +0,0 @@
-<script lang="ts" module>
- import { SaturnEffect } from "../lib/effects/SaturnEffect";
-
- // Global reference to the active Saturn effect for external control
- let globalSaturnEffect: SaturnEffect | null = null;
-
- export function getSaturnEffect(): SaturnEffect | null {
- return globalSaturnEffect;
- }
-</script>
-
-<script lang="ts">
- import { onMount, onDestroy } from "svelte";
- import { ConstellationEffect } from "../lib/effects/ConstellationEffect";
- import { settingsState } from "../stores/settings.svelte";
-
- let canvas: HTMLCanvasElement;
- let activeEffect: any;
-
- function loadEffect() {
- if (activeEffect) {
- activeEffect.destroy();
- }
-
- if (!canvas) return;
-
- if (settingsState.settings.active_effect === "saturn") {
- activeEffect = new SaturnEffect(canvas);
- globalSaturnEffect = activeEffect;
- } else {
- activeEffect = new ConstellationEffect(canvas);
- globalSaturnEffect = null;
- }
-
- // Ensure correct size immediately
- activeEffect.resize(window.innerWidth, window.innerHeight);
- }
-
- $effect(() => {
- const _ = settingsState.settings.active_effect;
- if (canvas) {
- loadEffect();
- }
- });
-
- onMount(() => {
- const resizeObserver = new ResizeObserver(() => {
- if (canvas && activeEffect) {
- activeEffect.resize(window.innerWidth, window.innerHeight);
- }
- });
-
- resizeObserver.observe(document.body);
-
- return () => {
- resizeObserver.disconnect();
- if (activeEffect) activeEffect.destroy();
- };
- });
-
- onDestroy(() => {
- if (activeEffect) activeEffect.destroy();
- globalSaturnEffect = null;
- });
-</script>
-
-<canvas
- bind:this={canvas}
- class="absolute inset-0 z-0 pointer-events-none"
-></canvas>
diff --git a/packages/ui/src/components/SettingsView.svelte b/packages/ui/src/components/SettingsView.svelte
deleted file mode 100644
index 0020506..0000000
--- a/packages/ui/src/components/SettingsView.svelte
+++ /dev/null
@@ -1,1217 +0,0 @@
-<script lang="ts">
- import { open } from "@tauri-apps/plugin-dialog";
- import { settingsState } from "../stores/settings.svelte";
- import CustomSelect from "./CustomSelect.svelte";
- import ConfigEditorModal from "./ConfigEditorModal.svelte";
- import { onMount } from "svelte";
- import { RefreshCw, FileJson } from "lucide-svelte";
-
- // Use convertFileSrc directly from settingsState.backgroundUrl for cleaner approach
- // or use the imported one if passing raw path.
- import { convertFileSrc } from "@tauri-apps/api/core";
-
- const effectOptions = [
- { value: "saturn", label: "Saturn" },
- { value: "constellation", label: "Network (Constellation)" }
- ];
-
- const logServiceOptions = [
- { value: "paste.rs", label: "paste.rs (Free, No Account)" },
- { value: "pastebin.com", label: "pastebin.com (Requires API Key)" }
- ];
-
- const llmProviderOptions = [
- { value: "ollama", label: "Ollama (Local)" },
- { value: "openai", label: "OpenAI (Remote)" }
- ];
-
- const languageOptions = [
- { value: "auto", label: "Auto (Match User)" },
- { value: "English", label: "English" },
- { value: "Chinese", label: "中文" },
- { value: "Japanese", label: "日本語" },
- { value: "Korean", label: "한국어" },
- { value: "Spanish", label: "Español" },
- { value: "French", label: "Français" },
- { value: "German", label: "Deutsch" },
- { value: "Russian", label: "Русский" },
- ];
-
- const ttsProviderOptions = [
- { value: "disabled", label: "Disabled" },
- { value: "piper", label: "Piper TTS (Local)" },
- { value: "edge", label: "Edge TTS (Online)" },
- ];
-
- const personas = [
- {
- value: "default",
- label: "Minecraft Expert (Default)",
- prompt: "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice."
- },
- {
- value: "technical",
- label: "Technical Debugger",
- prompt: "You are a technical support specialist for Minecraft. Focus strictly on analyzing logs, identifying crash causes, and providing technical solutions. Be precise and avoid conversational filler."
- },
- {
- value: "concise",
- label: "Concise Helper",
- prompt: "You are a direct and concise assistant. Provide answers in as few words as possible while remaining accurate. Use bullet points for lists."
- },
- {
- value: "explain",
- label: "Teacher / Explainer",
- prompt: "You are a patient teacher. Explain Minecraft concepts, redstone mechanics, and mod features in simple, easy-to-understand terms suitable for beginners."
- },
- {
- value: "pirate",
- label: "Pirate Captain",
- prompt: "You are a salty Minecraft Pirate Captain! Yarr! Speak like a pirate while helping the crew (the user) with their blocky adventures. Use terms like 'matey', 'landlubber', and 'treasure'."
- }
- ];
-
- let selectedPersona = $state("");
-
- function applyPersona(value: string) {
- const persona = personas.find(p => p.value === value);
- if (persona) {
- settingsState.settings.assistant.system_prompt = persona.prompt;
- selectedPersona = value; // Keep selected to show what's active
- }
- }
-
- function resetSystemPrompt() {
- const defaultPersona = personas.find(p => p.value === "default");
- if (defaultPersona) {
- settingsState.settings.assistant.system_prompt = defaultPersona.prompt;
- selectedPersona = "default";
- }
- }
-
- // Load models when assistant settings are shown
- function loadModelsForProvider() {
- if (settingsState.settings.assistant.llm_provider === "ollama") {
- settingsState.loadOllamaModels();
- } else if (settingsState.settings.assistant.llm_provider === "openai") {
- settingsState.loadOpenaiModels();
- }
- }
-
- async function selectBackground() {
- try {
- const selected = await open({
- multiple: false,
- filters: [
- {
- name: "Images",
- extensions: ["png", "jpg", "jpeg", "webp", "gif"],
- },
- ],
- });
-
- if (selected && typeof selected === "string") {
- settingsState.settings.custom_background_path = selected;
- settingsState.saveSettings();
- }
- } catch (e) {
- console.error("Failed to select background:", e);
- }
- }
-
- function clearBackground() {
- settingsState.settings.custom_background_path = undefined;
- settingsState.saveSettings();
- }
-
- let migrating = $state(false);
- async function runMigrationToSharedCaches() {
- if (migrating) return;
- migrating = true;
- try {
- const { invoke } = await import("@tauri-apps/api/core");
- const result = await invoke<{
- moved_files: number;
- hardlinks: number;
- copies: number;
- saved_mb: number;
- }>("migrate_shared_caches");
-
- // Reload settings to reflect changes
- await settingsState.loadSettings();
-
- // Show success message
- const msg = `Migration complete! ${result.moved_files} files (${result.hardlinks} hardlinks, ${result.copies} copies), ${result.saved_mb.toFixed(2)} MB saved.`;
- console.log(msg);
- alert(msg);
- } catch (e) {
- console.error("Migration failed:", e);
- alert(`Migration failed: ${e}`);
- } finally {
- migrating = false;
- }
- }
-</script>
-
-<div class="h-full flex flex-col p-6 overflow-hidden">
- <div class="flex items-center justify-between mb-6">
- <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">Settings</h2>
-
- <button
- onclick={() => settingsState.openConfigEditor()}
- class="p-2 hover:bg-white/10 rounded-lg text-zinc-400 hover:text-white transition-colors flex items-center gap-2 text-sm border border-transparent hover:border-white/5"
- title="Open Settings JSON"
- >
- <FileJson size={18} />
- <span class="hidden sm:inline">Open JSON</span>
- </button>
- </div>
-
- <div class="flex-1 overflow-y-auto pr-2 space-y-6 custom-scrollbar pb-10">
-
- <!-- Appearance / Background -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-6 flex items-center gap-2">
- Appearance
- </h3>
-
- <div class="space-y-4">
- <div>
- <label class="block text-sm font-medium dark:text-white/70 text-black/70 mb-3">Custom Background Image</label>
-
- <div class="flex items-center gap-6">
- <!-- Preview -->
- <div class="w-40 h-24 rounded-xl overflow-hidden dark:bg-black/50 bg-gray-200 border dark:border-white/10 border-black/10 relative group shadow-lg">
- {#if settingsState.settings.custom_background_path}
- <img
- src={convertFileSrc(settingsState.settings.custom_background_path)}
- alt="Background Preview"
- class="w-full h-full object-cover"
- onerror={(e) => {
- console.error("Failed to load image:", settingsState.settings.custom_background_path, e);
- // e.currentTarget.style.display = 'none';
- }}
- />
- {:else}
- <div class="w-full h-full bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950 opacity-100"></div>
- <div class="absolute inset-0 flex items-center justify-center text-[10px] text-white/50 bg-black/20 ">Default Gradient</div>
- {/if}
- </div>
-
- <!-- Actions -->
- <div class="flex flex-col gap-2">
- <button
- onclick={selectBackground}
- class="dark:bg-white/10 dark:hover:bg-white/20 bg-black/5 hover:bg-black/10 dark:text-white text-black px-4 py-2 rounded-lg text-sm transition-colors border dark:border-white/5 border-black/5"
- >
- Select Image
- </button>
-
- {#if settingsState.settings.custom_background_path}
- <button
- onclick={clearBackground}
- class="text-red-400 hover:text-red-300 text-sm px-4 py-1 text-left transition-colors"
- >
- Reset to Default
- </button>
- {/if}
- </div>
- </div>
- <p class="text-xs dark:text-white/30 text-black/40 mt-3">
- Select an image from your computer to replace the default gradient background.
- Supported formats: PNG, JPG, WEBP, GIF.
- </p>
- </div>
-
- <!-- Visual Settings -->
- <div class="pt-4 border-t dark:border-white/5 border-black/5 space-y-4">
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="visual-effects-label">Visual Effects</h4>
- <p class="text-xs dark:text-white/40 text-black/50 mt-1">Enable particle effects and animated gradients. (Default: On)</p>
- </div>
- <button
- aria-labelledby="visual-effects-label"
- onclick={() => { settingsState.settings.enable_visual_effects = !settingsState.settings.enable_visual_effects; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.enable_visual_effects ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.enable_visual_effects ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- {#if settingsState.settings.enable_visual_effects}
- <div class="flex items-center justify-between pl-2 border-l-2 dark:border-white/5 border-black/5 ml-1">
- <div>
- <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="theme-effect-label">Theme Effect</h4>
- <p class="text-xs dark:text-white/40 text-black/50 mt-1">Select the active visual theme.</p>
- </div>
- <CustomSelect
- options={effectOptions}
- bind:value={settingsState.settings.active_effect}
- onchange={() => settingsState.saveSettings()}
- class="w-52"
- />
- </div>
- {/if}
-
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="gpu-acceleration-label">GPU Acceleration</h4>
- <p class="text-xs dark:text-white/40 text-black/50 mt-1">Enable GPU acceleration for the interface. (Default: Off, Requires Restart)</p>
- </div>
- <button
- aria-labelledby="gpu-acceleration-label"
- onclick={() => { settingsState.settings.enable_gpu_acceleration = !settingsState.settings.enable_gpu_acceleration; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.enable_gpu_acceleration ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.enable_gpu_acceleration ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- <!-- Color Theme Switcher -->
- <div class="flex items-center justify-between pt-4 border-t dark:border-white/5 border-black/5 opacity-50 cursor-not-allowed">
- <div>
- <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="color-theme-label">Color Theme</h4>
- <p class="text-xs dark:text-white/40 text-black/50 mt-1">Interface color mode. (Locked to Dark)</p>
- </div>
- <div class="flex items-center bg-black/5 dark:bg-white/5 rounded-lg p-1 pointer-events-none">
- <button
- disabled
- class="px-3 py-1 rounded-md text-xs font-medium transition-all text-gray-500 dark:text-gray-600"
- >
- Light
- </button>
- <button
- disabled
- class="px-3 py-1 rounded-md text-xs font-medium transition-all bg-indigo-500 text-white shadow-sm"
- >
- Dark
- </button>
- </div>
- </div>
- </div>
- </div>
- </div>
-
- <!-- Java Path -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
- Java Environment
- </h3>
- <div class="space-y-4">
- <div>
- <label for="java-path" class="block text-sm font-medium text-white/70 mb-2">Java Executable Path</label>
- <div class="flex gap-2">
- <input
- id="java-path"
- bind:value={settingsState.settings.java_path}
- type="text"
- class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
- placeholder="e.g. java, /usr/bin/java"
- />
- <button
- onclick={() => settingsState.detectJava()}
- disabled={settingsState.isDetectingJava}
- class="bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white px-4 py-2 rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium"
- >
- {settingsState.isDetectingJava ? "Detecting..." : "Auto Detect"}
- </button>
- <button
- onclick={() => settingsState.openJavaDownloadModal()}
- class="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-xl transition-colors whitespace-nowrap text-sm font-medium"
- >
- Download Java
- </button>
- </div>
- </div>
-
- {#if settingsState.javaInstallations.length > 0}
- <div class="mt-4 space-y-2">
- <p class="text-[10px] text-white/30 uppercase font-bold tracking-wider">Detected Installations</p>
- {#each settingsState.javaInstallations as java}
- <button
- onclick={() => settingsState.selectJava(java.path)}
- class="w-full text-left p-3 rounded-lg border transition-all duration-200 group
- {settingsState.settings.java_path === java.path
- ? 'bg-indigo-500/20 border-indigo-500/30'
- : 'bg-black/20 border-white/5 hover:bg-white/5 hover:border-white/10'}"
- >
- <div class="flex justify-between items-center">
- <div>
- <span class="text-white font-mono text-xs font-bold">{java.version}</span>
- <span class="text-white/40 text-[10px] ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span>
- </div>
- {#if settingsState.settings.java_path === java.path}
- <span class="text-indigo-300 text-[10px] font-bold uppercase tracking-wider">Selected</span>
- {/if}
- </div>
- <div class="text-white/30 text-[10px] font-mono truncate mt-1 group-hover:text-white/50">{java.path}</div>
- </button>
- {/each}
- </div>
- {/if}
- </div>
- </div>
-
- <!-- Memory -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
- Memory Allocation (RAM)
- </h3>
- <div class="grid grid-cols-2 gap-6">
- <div>
- <label for="min-memory" class="block text-sm font-medium text-white/70 mb-2">Minimum (MB)</label>
- <input
- id="min-memory"
- bind:value={settingsState.settings.min_memory}
- type="number"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
- />
- </div>
- <div>
- <label for="max-memory" class="block text-sm font-medium text-white/70 mb-2">Maximum (MB)</label>
- <input
- id="max-memory"
- bind:value={settingsState.settings.max_memory}
- type="number"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
- />
- </div>
- </div>
- </div>
-
- <!-- Resolution -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
- Game Window Size
- </h3>
- <div class="grid grid-cols-2 gap-6">
- <div>
- <label for="window-width" class="block text-sm font-medium text-white/70 mb-2">Width</label>
- <input
- id="window-width"
- bind:value={settingsState.settings.width}
- type="number"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
- />
- </div>
- <div>
- <label for="window-height" class="block text-sm font-medium text-white/70 mb-2">Height</label>
- <input
- id="window-height"
- bind:value={settingsState.settings.height}
- type="number"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
- />
- </div>
- </div>
- </div>
-
- <!-- Download Settings -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
- Network
- </h3>
- <div>
- <label for="download-threads" class="block text-sm font-medium text-white/70 mb-2">Concurrent Download Threads</label>
- <input
- id="download-threads"
- bind:value={settingsState.settings.download_threads}
- type="number"
- min="1"
- max="128"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
- />
- <p class="text-xs text-white/30 mt-2">Higher values usually mean faster downloads but use more CPU/Network.</p>
- </div>
- </div>
-
- <!-- Storage & Caches -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">Storage & Version Caches</h3>
- <div class="space-y-4">
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium text-white/90" id="shared-caches-label">Use Shared Caches</h4>
- <p class="text-xs text-white/40 mt-1">Store versions/libraries/assets in a global cache shared by all instances.</p>
- </div>
- <button
- aria-labelledby="shared-caches-label"
- onclick={() => { settingsState.settings.use_shared_caches = !settingsState.settings.use_shared_caches; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.use_shared_caches ? 'bg-indigo-500' : 'bg-white/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.use_shared_caches ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium text-white/90" id="legacy-storage-label">Keep Legacy Per-Instance Storage</h4>
- <p class="text-xs text-white/40 mt-1">Do not migrate existing instance caches; keep current layout.</p>
- </div>
- <button
- aria-labelledby="legacy-storage-label"
- onclick={() => { settingsState.settings.keep_legacy_per_instance_storage = !settingsState.settings.keep_legacy_per_instance_storage; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.keep_legacy_per_instance_storage ? 'bg-indigo-500' : 'bg-white/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.keep_legacy_per_instance_storage ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- <div class="flex items-center justify-between pt-2 border-t border-white/10">
- <div>
- <h4 class="text-sm font-medium text-white/90">Run Migration</h4>
- <p class="text-xs text-white/40 mt-1">Hard-link or copy existing per-instance caches into the shared cache.</p>
- </div>
- <button
- onclick={runMigrationToSharedCaches}
- disabled={migrating}
- class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm disabled:opacity-50 disabled:cursor-not-allowed"
- >
- {migrating ? "Migrating..." : "Migrate Now"}
- </button>
- </div>
- </div>
- </div>
-
- <!-- Feature Flags -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">Feature Flags (Launcher Arguments)</h3>
- <div class="space-y-4">
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium text-white/90" id="demo-user-label">Demo User</h4>
- <p class="text-xs text-white/40 mt-1">Enable demo-related arguments when rules require them.</p>
- </div>
- <button
- aria-labelledby="demo-user-label"
- onclick={() => { settingsState.settings.feature_flags.demo_user = !settingsState.settings.feature_flags.demo_user; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.demo_user ? 'bg-indigo-500' : 'bg-white/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.feature_flags.demo_user ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium text-white/90" id="quick-play-label">Quick Play</h4>
- <p class="text-xs text-white/40 mt-1">Enable quick play singleplayer/multiplayer arguments.</p>
- </div>
- <button
- aria-labelledby="quick-play-label"
- onclick={() => { settingsState.settings.feature_flags.quick_play_enabled = !settingsState.settings.feature_flags.quick_play_enabled; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.quick_play_enabled ? 'bg-indigo-500' : 'bg-white/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.feature_flags.quick_play_enabled ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- {#if settingsState.settings.feature_flags.quick_play_enabled}
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-2 border-l-2 border-white/10">
- <div>
- <label class="block text-sm font-medium text-white/70 mb-2">Singleplayer World Path</label>
- <input
- type="text"
- bind:value={settingsState.settings.feature_flags.quick_play_path}
- placeholder="/path/to/saves/MyWorld"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
- />
- </div>
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium text-white/90" id="qp-singleplayer-label">Prefer Singleplayer</h4>
- <p class="text-xs text-white/40 mt-1">If enabled, use singleplayer quick play path.</p>
- </div>
- <button
- aria-labelledby="qp-singleplayer-label"
- onclick={() => { settingsState.settings.feature_flags.quick_play_singleplayer = !settingsState.settings.feature_flags.quick_play_singleplayer; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.quick_play_singleplayer ? 'bg-indigo-500' : 'bg-white/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.feature_flags.quick_play_singleplayer ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
- <div>
- <label class="block text-sm font-medium text-white/70 mb-2">Multiplayer Server Address</label>
- <input
- type="text"
- bind:value={settingsState.settings.feature_flags.quick_play_multiplayer_server}
- placeholder="example.org:25565"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
- />
- </div>
- </div>
- {/if}
- </div>
- </div>
-
- <!-- Debug / Logs -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
- Debug & Logs
- </h3>
- <div class="space-y-4">
- <div>
- <label for="log-service" class="block text-sm font-medium text-white/70 mb-2">Log Upload Service</label>
- <CustomSelect
- options={logServiceOptions}
- bind:value={settingsState.settings.log_upload_service}
- class="w-full"
- />
- </div>
-
- {#if settingsState.settings.log_upload_service === 'pastebin.com'}
- <div>
- <label for="pastebin-key" class="block text-sm font-medium text-white/70 mb-2">Pastebin Dev API Key</label>
- <input
- id="pastebin-key"
- type="password"
- bind:value={settingsState.settings.pastebin_api_key}
- placeholder="Enter your API Key"
- class="dark:bg-zinc-900 bg-white dark:text-white text-black w-full px-4 py-3 rounded-xl border dark:border-zinc-700 border-gray-300 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 outline-none transition-colors placeholder:text-zinc-500"
- />
- <p class="text-xs text-white/30 mt-2">
- Get your API key from <a href="https://pastebin.com/doc_api#1" target="_blank" class="text-indigo-400 hover:underline">Pastebin API Documentation</a>.
- </p>
- </div>
- {/if}
- </div>
- </div>
-
- <!-- AI Assistant -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
- <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <rect x="3" y="11" width="18" height="10" rx="2"/>
- <circle cx="12" cy="5" r="2"/>
- <path d="M12 7v4"/>
- <circle cx="8" cy="16" r="1" fill="currentColor"/>
- <circle cx="16" cy="16" r="1" fill="currentColor"/>
- </svg>
- AI Assistant
- </h3>
- <div class="space-y-6">
- <!-- Enable/Disable -->
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="assistant-enabled-label">Enable Assistant</h4>
- <p class="text-xs dark:text-white/40 text-black/50 mt-1">Toggle the AI assistant feature on or off.</p>
- </div>
- <button
- aria-labelledby="assistant-enabled-label"
- onclick={() => { settingsState.settings.assistant.enabled = !settingsState.settings.assistant.enabled; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.assistant.enabled ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.assistant.enabled ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- {#if settingsState.settings.assistant.enabled}
- <!-- LLM Provider Section -->
- <div class="pt-4 border-t dark:border-white/5 border-black/5">
- <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Language Model</h4>
-
- <div class="space-y-4">
- <div>
- <label for="llm-provider" class="block text-sm font-medium text-white/70 mb-2">Provider</label>
- <CustomSelect
- options={llmProviderOptions}
- bind:value={settingsState.settings.assistant.llm_provider}
- onchange={() => settingsState.saveSettings()}
- class="w-full"
- />
- </div>
-
- {#if settingsState.settings.assistant.llm_provider === 'ollama'}
- <!-- Ollama Settings -->
- <div class="pl-4 border-l-2 border-indigo-500/30 space-y-4">
- <div>
- <label for="ollama-endpoint" class="block text-sm font-medium text-white/70 mb-2">API Endpoint</label>
- <div class="flex gap-2">
- <input
- id="ollama-endpoint"
- type="text"
- bind:value={settingsState.settings.assistant.ollama_endpoint}
- placeholder="http://localhost:11434"
- class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
- />
- <button
- onclick={() => settingsState.loadOllamaModels()}
- disabled={settingsState.isLoadingOllamaModels}
- class="px-4 py-2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium flex items-center gap-2"
- title="Refresh models"
- >
- <RefreshCw size={14} class={settingsState.isLoadingOllamaModels ? "animate-spin" : ""} />
- <span class="hidden sm:inline">Refresh</span>
- </button>
- </div>
- <p class="text-xs text-white/30 mt-2">
- Default: http://localhost:11434. Make sure Ollama is running.
- </p>
- </div>
-
- <div>
- <div class="flex items-center justify-between mb-2">
- <label for="ollama-model" class="block text-sm font-medium text-white/70">Model</label>
- {#if settingsState.ollamaModels.length > 0}
- <span class="text-[10px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
- {settingsState.ollamaModels.length} installed
- </span>
- {/if}
- </div>
-
- {#if settingsState.isLoadingOllamaModels}
- <div class="bg-black/40 text-white/50 w-full px-4 py-3 rounded-xl border border-white/10 text-sm flex items-center gap-2">
- <RefreshCw size={14} class="animate-spin" />
- Loading models...
- </div>
- {:else if settingsState.ollamaModelsError}
- <div class="bg-red-500/10 text-red-400 w-full px-4 py-3 rounded-xl border border-red-500/20 text-sm">
- {settingsState.ollamaModelsError}
- </div>
- <CustomSelect
- options={settingsState.currentModelOptions}
- bind:value={settingsState.settings.assistant.ollama_model}
- onchange={() => settingsState.saveSettings()}
- class="w-full mt-2"
- allowCustom={true}
- />
- {:else if settingsState.ollamaModels.length === 0}
- <div class="bg-amber-500/10 text-amber-400 w-full px-4 py-3 rounded-xl border border-amber-500/20 text-sm">
- No models found. Click Refresh to load installed models.
- </div>
- <CustomSelect
- options={settingsState.currentModelOptions}
- bind:value={settingsState.settings.assistant.ollama_model}
- onchange={() => settingsState.saveSettings()}
- class="w-full mt-2"
- allowCustom={true}
- />
- {:else}
- <CustomSelect
- options={settingsState.currentModelOptions}
- bind:value={settingsState.settings.assistant.ollama_model}
- onchange={() => settingsState.saveSettings()}
- class="w-full"
- allowCustom={true}
- />
- {/if}
-
- <p class="text-xs text-white/30 mt-2">
- Run <code class="bg-black/30 px-1 rounded">ollama pull {'<model>'}</code> to download new models. Or type a custom model name above.
- </p>
- </div>
- </div>
- {:else if settingsState.settings.assistant.llm_provider === 'openai'}
- <!-- OpenAI Settings -->
- <div class="pl-4 border-l-2 border-emerald-500/30 space-y-4">
- <div>
- <label for="openai-key" class="block text-sm font-medium text-white/70 mb-2">API Key</label>
- <div class="flex gap-2">
- <input
- id="openai-key"
- type="password"
- bind:value={settingsState.settings.assistant.openai_api_key}
- placeholder="sk-..."
- class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
- />
- <button
- onclick={() => settingsState.loadOpenaiModels()}
- disabled={settingsState.isLoadingOpenaiModels || !settingsState.settings.assistant.openai_api_key}
- class="px-4 py-2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium flex items-center gap-2"
- title="Refresh models"
- >
- <RefreshCw size={14} class={settingsState.isLoadingOpenaiModels ? "animate-spin" : ""} />
- <span class="hidden sm:inline">Load</span>
- </button>
- </div>
- <p class="text-xs text-white/30 mt-2">
- Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank" class="text-indigo-400 hover:underline">OpenAI Dashboard</a>.
- </p>
- </div>
-
- <div>
- <label for="openai-endpoint" class="block text-sm font-medium text-white/70 mb-2">API Endpoint</label>
- <input
- id="openai-endpoint"
- type="text"
- bind:value={settingsState.settings.assistant.openai_endpoint}
- placeholder="https://api.openai.com/v1"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
- />
- <p class="text-xs text-white/30 mt-2">
- Use custom endpoint for Azure OpenAI or other compatible APIs.
- </p>
- </div>
-
- <div>
- <div class="flex items-center justify-between mb-2">
- <label for="openai-model" class="block text-sm font-medium text-white/70">Model</label>
- {#if settingsState.openaiModels.length > 0}
- <span class="text-[10px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
- {settingsState.openaiModels.length} available
- </span>
- {/if}
- </div>
-
- {#if settingsState.isLoadingOpenaiModels}
- <div class="bg-black/40 text-white/50 w-full px-4 py-3 rounded-xl border border-white/10 text-sm flex items-center gap-2">
- <RefreshCw size={14} class="animate-spin" />
- Loading models...
- </div>
- {:else if settingsState.openaiModelsError}
- <div class="bg-red-500/10 text-red-400 w-full px-4 py-3 rounded-xl border border-red-500/20 text-sm mb-2">
- {settingsState.openaiModelsError}
- </div>
- <CustomSelect
- options={settingsState.currentModelOptions}
- bind:value={settingsState.settings.assistant.openai_model}
- onchange={() => settingsState.saveSettings()}
- class="w-full"
- allowCustom={true}
- />
- {:else}
- <CustomSelect
- options={settingsState.currentModelOptions}
- bind:value={settingsState.settings.assistant.openai_model}
- onchange={() => settingsState.saveSettings()}
- class="w-full"
- allowCustom={true}
- />
- {/if}
- </div>
- </div>
- {/if}
- </div>
- </div>
-
- <!-- Response Settings -->
- <div class="pt-4 border-t dark:border-white/5 border-black/5">
- <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Response Settings</h4>
-
- <div class="space-y-4">
- <div>
- <label for="response-lang" class="block text-sm font-medium text-white/70 mb-2">Response Language</label>
- <CustomSelect
- options={languageOptions}
- bind:value={settingsState.settings.assistant.response_language}
- onchange={() => settingsState.saveSettings()}
- class="w-full"
- />
- </div>
-
- <div>
- <div class="flex items-center justify-between mb-2">
- <label for="system-prompt" class="block text-sm font-medium text-white/70">System Prompt</label>
- <button
- onclick={resetSystemPrompt}
- class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1 opacity-80 hover:opacity-100"
- title="Reset to default prompt"
- >
- <RefreshCw size={10} />
- Reset
- </button>
- </div>
-
- <div class="mb-3">
- <CustomSelect
- options={personas.map(p => ({ value: p.value, label: p.label }))}
- bind:value={selectedPersona}
- placeholder="Load a preset persona..."
- onchange={applyPersona}
- class="w-full"
- />
- </div>
-
- <textarea
- id="system-prompt"
- bind:value={settingsState.settings.assistant.system_prompt}
- oninput={() => selectedPersona = ""}
- rows="4"
- placeholder="You are a helpful Minecraft expert assistant..."
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none text-sm transition-colors resize-none"
- ></textarea>
- <p class="text-xs text-white/30 mt-2">
- Customize how the assistant behaves and responds.
- </p>
- </div>
- </div>
- </div>
-
- <!-- TTS Settings -->
- <div class="pt-4 border-t dark:border-white/5 border-black/5">
- <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Text-to-Speech (Coming Soon)</h4>
-
- <div class="space-y-4 opacity-50 pointer-events-none">
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium dark:text-white/90 text-black/80">Enable TTS</h4>
- <p class="text-xs dark:text-white/40 text-black/50 mt-1">Read assistant responses aloud.</p>
- </div>
- <button
- disabled
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none dark:bg-white/10 bg-black/10"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out translate-x-0"></div>
- </button>
- </div>
-
- <div>
- <label class="block text-sm font-medium text-white/70 mb-2">TTS Provider</label>
- <CustomSelect
- options={ttsProviderOptions}
- value="disabled"
- class="w-full"
- />
- </div>
- </div>
- </div>
- {/if}
- </div>
- </div>
-
- <div class="pt-4 flex justify-end">
- <button
- onclick={() => settingsState.saveSettings()}
- class="bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-500 hover:to-green-500 text-white font-bold py-3 px-8 rounded-xl shadow-lg shadow-emerald-500/20 transition-all hover:scale-105 active:scale-95"
- >
- Save Settings
- </button>
- </div>
- </div>
-</div>
-
-{#if settingsState.showConfigEditor}
- <ConfigEditorModal />
-{/if}
-
-<!-- Java Download Modal -->
-{#if settingsState.showJavaDownloadModal}
- <div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70">
- <div class="bg-zinc-900 rounded-2xl border border-white/10 shadow-2xl w-[900px] max-w-[95vw] h-[600px] max-h-[90vh] flex flex-col overflow-hidden">
- <!-- Header -->
- <div class="flex items-center justify-between p-5 border-b border-white/10">
- <h3 class="text-xl font-bold text-white">Download Java</h3>
- <button
- aria-label="Close dialog"
- onclick={() => settingsState.closeJavaDownloadModal()}
- disabled={settingsState.isDownloadingJava}
- class="text-white/40 hover:text-white/80 disabled:opacity-50 transition-colors p-1"
- >
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
- </svg>
- </button>
- </div>
-
- <!-- Main Content Area -->
- <div class="flex flex-1 overflow-hidden">
- <!-- Left Sidebar: Sources -->
- <div class="w-40 border-r border-white/10 p-3 flex flex-col gap-1">
- <span class="text-[10px] font-bold uppercase tracking-widest text-white/30 px-2 mb-2">Sources</span>
-
- <button
- disabled
- class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm opacity-40 cursor-not-allowed text-white/50"
- >
- <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center text-[10px]">M</div>
- Mojang
- </button>
-
- <button
- class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm bg-indigo-500/20 border border-indigo-500/40 text-white"
- >
- <div class="w-5 h-5 rounded bg-indigo-500 flex items-center justify-center text-[10px] font-bold">A</div>
- Adoptium
- </button>
-
- <button
- disabled
- class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm opacity-40 cursor-not-allowed text-white/50"
- >
- <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center text-[10px]">Z</div>
- Azul Zulu
- </button>
- </div>
-
- <!-- Center: Version Selection -->
- <div class="flex-1 flex flex-col overflow-hidden">
- <!-- Toolbar -->
- <div class="flex items-center gap-3 p-4 border-b border-white/5">
- <!-- Search -->
- <div class="relative flex-1 max-w-xs">
- <input
- type="text"
- bind:value={settingsState.searchQuery}
- placeholder="Search versions..."
- class="w-full bg-black/30 text-white text-sm px-4 py-2 pl-9 rounded-lg border border-white/10 focus:border-indigo-500/50 outline-none"
- />
- <svg class="absolute left-3 top-2.5 w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
- </svg>
- </div>
-
- <!-- Recommended Filter -->
- <label class="flex items-center gap-2 text-sm text-white/60 cursor-pointer select-none">
- <input
- type="checkbox"
- bind:checked={settingsState.showOnlyRecommended}
- class="w-4 h-4 rounded border-white/20 bg-black/30 text-indigo-500 focus:ring-indigo-500/30"
- />
- LTS Only
- </label>
-
- <!-- Image Type Toggle -->
- <div class="flex items-center bg-black/30 rounded-lg p-0.5 border border-white/10">
- <button
- onclick={() => settingsState.selectedImageType = "jre"}
- class="px-3 py-1.5 rounded-md text-xs font-medium transition-all {settingsState.selectedImageType === 'jre' ? 'bg-indigo-500 text-white shadow' : 'text-white/50 hover:text-white/80'}"
- >
- JRE
- </button>
- <button
- onclick={() => settingsState.selectedImageType = "jdk"}
- class="px-3 py-1.5 rounded-md text-xs font-medium transition-all {settingsState.selectedImageType === 'jdk' ? 'bg-indigo-500 text-white shadow' : 'text-white/50 hover:text-white/80'}"
- >
- JDK
- </button>
- </div>
- </div>
-
- <!-- Loading State -->
- {#if settingsState.isLoadingCatalog}
- <div class="flex-1 flex items-center justify-center text-white/50">
- <div class="flex flex-col items-center gap-3">
- <div class="w-8 h-8 border-2 border-indigo-500/30 border-t-indigo-500 rounded-full animate-spin"></div>
- <span class="text-sm">Loading Java versions...</span>
- </div>
- </div>
- {:else if settingsState.catalogError}
- <div class="flex-1 flex items-center justify-center text-red-400">
- <div class="flex flex-col items-center gap-3 text-center px-8">
- <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
- </svg>
- <span class="text-sm">{settingsState.catalogError}</span>
- <button
- onclick={() => settingsState.refreshCatalog()}
- class="mt-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-sm text-white transition-colors"
- >
- Retry
- </button>
- </div>
- </div>
- {:else}
- <!-- Version List -->
- <div class="flex-1 overflow-auto p-4">
- <div class="space-y-2">
- {#each settingsState.availableMajorVersions as version}
- {@const isLts = settingsState.javaCatalog?.lts_versions.includes(version)}
- {@const isSelected = settingsState.selectedMajorVersion === version}
- {@const releaseInfo = settingsState.javaCatalog?.releases.find(r => r.major_version === version && r.image_type === settingsState.selectedImageType)}
- {@const isAvailable = releaseInfo?.is_available ?? false}
- {@const installStatus = releaseInfo ? settingsState.getInstallStatus(releaseInfo) : 'download'}
-
- <button
- onclick={() => settingsState.selectMajorVersion(version)}
- disabled={!isAvailable}
- class="w-full flex items-center gap-4 p-3 rounded-xl border transition-all text-left
- {isSelected
- ? 'bg-indigo-500/20 border-indigo-500/50 ring-2 ring-indigo-500/30'
- : isAvailable
- ? 'bg-black/20 border-white/10 hover:bg-white/5 hover:border-white/20'
- : 'bg-black/10 border-white/5 opacity-40 cursor-not-allowed'}"
- >
- <!-- Version Number -->
- <div class="w-14 text-center">
- <span class="text-xl font-bold {isSelected ? 'text-white' : 'text-white/80'}">{version}</span>
- </div>
-
- <!-- Version Details -->
- <div class="flex-1 min-w-0">
- <div class="flex items-center gap-2">
- <span class="text-sm text-white/70 font-mono truncate">{releaseInfo?.version ?? '--'}</span>
- {#if isLts}
- <span class="px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 text-[9px] font-bold rounded uppercase shrink-0">LTS</span>
- {/if}
- </div>
- {#if releaseInfo}
- <div class="text-[10px] text-white/40 truncate mt-0.5">
- {releaseInfo.release_name} • {settingsState.formatBytes(releaseInfo.file_size)}
- </div>
- {/if}
- </div>
-
- <!-- Install Status Badge -->
- <div class="shrink-0">
- {#if installStatus === 'installed'}
- <span class="px-2 py-1 bg-emerald-500/20 text-emerald-400 text-[10px] font-bold rounded uppercase">Installed</span>
- {:else if isAvailable}
- <span class="px-2 py-1 bg-white/10 text-white/50 text-[10px] font-medium rounded">Download</span>
- {:else}
- <span class="px-2 py-1 bg-red-500/10 text-red-400/60 text-[10px] font-medium rounded">N/A</span>
- {/if}
- </div>
- </button>
- {/each}
- </div>
- </div>
- {/if}
- </div>
-
- <!-- Right Sidebar: Details -->
- <div class="w-64 border-l border-white/10 flex flex-col">
- <div class="p-4 border-b border-white/5">
- <span class="text-[10px] font-bold uppercase tracking-widest text-white/30">Details</span>
- </div>
-
- {#if settingsState.selectedRelease}
- <div class="flex-1 p-4 space-y-4 overflow-auto">
- <div>
- <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Version</div>
- <div class="text-sm text-white font-mono">{settingsState.selectedRelease.version}</div>
- </div>
-
- <div>
- <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Release Name</div>
- <div class="text-sm text-white">{settingsState.selectedRelease.release_name}</div>
- </div>
-
- <div>
- <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Release Date</div>
- <div class="text-sm text-white">{settingsState.formatDate(settingsState.selectedRelease.release_date)}</div>
- </div>
-
- <div>
- <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Size</div>
- <div class="text-sm text-white">{settingsState.formatBytes(settingsState.selectedRelease.file_size)}</div>
- </div>
-
- <div>
- <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Type</div>
- <div class="flex items-center gap-2">
- <span class="text-sm text-white uppercase">{settingsState.selectedRelease.image_type}</span>
- {#if settingsState.selectedRelease.is_lts}
- <span class="px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 text-[9px] font-bold rounded">LTS</span>
- {/if}
- </div>
- </div>
-
- <div>
- <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Architecture</div>
- <div class="text-sm text-white">{settingsState.selectedRelease.architecture}</div>
- </div>
-
- {#if !settingsState.selectedRelease.is_available}
- <div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
- <div class="text-xs text-red-400">Not available for your platform</div>
- </div>
- {/if}
- </div>
- {:else}
- <div class="flex-1 flex items-center justify-center text-white/30 text-sm p-4 text-center">
- Select a Java version to view details
- </div>
- {/if}
- </div>
- </div>
-
- <!-- Download Progress (MC Style) -->
- {#if settingsState.isDownloadingJava && settingsState.downloadProgress}
- <div class="border-t border-white/10 p-4 bg-zinc-900/90">
- <div class="flex items-center justify-between mb-2">
- <h3 class="text-white font-bold text-sm">Downloading Java</h3>
- <span class="text-xs text-zinc-400">{settingsState.downloadProgress.status}</span>
- </div>
-
- <!-- Progress Bar -->
- <div class="mb-2">
- <div class="flex justify-between text-[10px] text-zinc-400 mb-1">
- <span>{settingsState.downloadProgress.file_name}</span>
- <span>{Math.round(settingsState.downloadProgress.percentage)}%</span>
- </div>
- <div class="w-full bg-zinc-800 rounded-full h-2.5 overflow-hidden">
- <div
- class="bg-gradient-to-r from-blue-500 to-cyan-400 h-2.5 rounded-full transition-all duration-200"
- style="width: {settingsState.downloadProgress.percentage}%"
- ></div>
- </div>
- </div>
-
- <!-- Speed & Stats -->
- <div class="flex justify-between text-[10px] text-zinc-500 font-mono">
- <span>
- {settingsState.formatBytes(settingsState.downloadProgress.speed_bytes_per_sec)}/s ·
- ETA: {settingsState.formatTime(settingsState.downloadProgress.eta_seconds)}
- </span>
- <span>
- {settingsState.formatBytes(settingsState.downloadProgress.downloaded_bytes)} /
- {settingsState.formatBytes(settingsState.downloadProgress.total_bytes)}
- </span>
- </div>
- </div>
- {/if}
-
- <!-- Pending Downloads Alert -->
- {#if settingsState.pendingDownloads.length > 0 && !settingsState.isDownloadingJava}
- <div class="border-t border-amber-500/30 p-4 bg-amber-500/10">
- <div class="flex items-center justify-between">
- <div class="flex items-center gap-3">
- <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
- </svg>
- <span class="text-sm text-amber-200">
- {settingsState.pendingDownloads.length} pending download(s) can be resumed
- </span>
- </div>
- <button
- onclick={() => settingsState.resumeDownloads()}
- class="px-4 py-2 bg-amber-500/20 hover:bg-amber-500/30 text-amber-200 rounded-lg text-sm font-medium transition-colors"
- >
- Resume All
- </button>
- </div>
- </div>
- {/if}
-
- <!-- Footer Actions -->
- <div class="flex items-center justify-between p-4 border-t border-white/10 bg-black/20">
- <button
- onclick={() => settingsState.refreshCatalog()}
- disabled={settingsState.isLoadingCatalog || settingsState.isDownloadingJava}
- class="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 disabled:opacity-50 text-white/70 rounded-lg text-sm transition-colors"
- >
- <svg class="w-4 h-4 {settingsState.isLoadingCatalog ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
- </svg>
- Refresh
- </button>
-
- <div class="flex gap-3">
- {#if settingsState.isDownloadingJava}
- <button
- onclick={() => settingsState.cancelDownload()}
- class="px-5 py-2.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-colors"
- >
- Cancel Download
- </button>
- {:else}
- {@const isInstalled = settingsState.selectedRelease ? settingsState.getInstallStatus(settingsState.selectedRelease) === 'installed' : false}
- <button
- onclick={() => settingsState.closeJavaDownloadModal()}
- class="px-5 py-2.5 bg-white/10 hover:bg-white/20 text-white rounded-lg text-sm font-medium transition-colors"
- >
- Close
- </button>
- <button
- onclick={() => settingsState.downloadJava()}
- disabled={!settingsState.selectedRelease?.is_available || settingsState.isLoadingCatalog || isInstalled}
- class="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors"
- >
- {isInstalled ? 'Already Installed' : 'Download & Install'}
- </button>
- {/if}
- </div>
- </div>
- </div>
- </div>
-{/if}
diff --git a/packages/ui/src/components/Sidebar.svelte b/packages/ui/src/components/Sidebar.svelte
deleted file mode 100644
index 83f4ac6..0000000
--- a/packages/ui/src/components/Sidebar.svelte
+++ /dev/null
@@ -1,91 +0,0 @@
-<script lang="ts">
- import { uiState } from '../stores/ui.svelte';
- import { Home, Package, Settings, Bot, Folder } from 'lucide-svelte';
-</script>
-
-<aside
- class="w-20 lg:w-64 dark:bg-[#09090b] bg-white border-r dark:border-white/10 border-gray-200 flex flex-col items-center lg:items-start transition-all duration-300 shrink-0 py-6 z-20"
->
- <!-- Logo Area -->
- <div
- class="h-16 w-full flex items-center justify-center lg:justify-start lg:px-6 mb-6"
- >
- <!-- Icon Logo (Small) -->
- <div class="lg:hidden text-black dark:text-white">
- <svg width="32" height="32" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M25 25 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
- <path d="M25 75 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
- <path d="M50 50 L75 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
- <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
- <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
- <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
- <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
- <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
- </svg>
- </div>
- <!-- Full Logo (Large) -->
- <div
- class="hidden lg:flex items-center gap-3 font-bold text-xl tracking-tighter dark:text-white text-black"
- >
- <!-- Neural Network Dropout Logo -->
- <svg width="42" height="42" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" class="shrink-0">
- <!-- Lines -->
- <path d="M25 25 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
- <path d="M25 75 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
- <path d="M50 50 L75 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
-
- <!-- Input Layer (Left) -->
- <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
- <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
- <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
-
- <!-- Hidden Layer (Middle) - Dropout visualization -->
- <!-- Dropped units (dashed) -->
- <circle cx="50" cy="25" r="7" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2" fill="none" class="opacity-30" />
- <circle cx="50" cy="75" r="7" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2" fill="none" class="opacity-30" />
- <!-- Active unit -->
- <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
-
- <!-- Output Layer (Right) -->
- <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
- </svg>
-
- <span>DROPOUT</span>
- </div>
- </div>
-
- <!-- Navigation -->
- <nav class="flex-1 w-full flex flex-col gap-1 px-3">
- <!-- Nav Item Helper -->
- {#snippet navItem(view: any, Icon: any, label: string)}
- <button
- class="group flex items-center lg:gap-3 justify-center lg:justify-start w-full px-0 lg:px-4 py-2.5 rounded-sm transition-all duration-200 relative
- {uiState.currentView === view
- ? 'bg-black/5 dark:bg-white/10 dark:text-white text-black font-medium'
- : 'dark:text-zinc-400 text-zinc-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
- onclick={() => uiState.setView(view)}
- >
- <Icon size={20} strokeWidth={uiState.currentView === view ? 2.5 : 2} />
- <span class="hidden lg:block text-sm relative z-10">{label}</span>
-
- <!-- Active Indicator -->
- {#if uiState.currentView === view}
- <div class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-black dark:bg-white rounded-r-full hidden lg:block"></div>
- {/if}
- </button>
- {/snippet}
-
- {@render navItem('home', Home, 'Overview')}
- {@render navItem('instances', Folder, 'Instances')}
- {@render navItem('versions', Package, 'Versions')}
- {@render navItem('guide', Bot, 'Assistant')}
- {@render navItem('settings', Settings, 'Settings')}
- </nav>
-
- <!-- Footer Info -->
- <div
- class="p-4 w-full flex justify-center lg:justify-start lg:px-6 opacity-40 hover:opacity-100 transition-opacity"
- >
- <div class="text-[10px] font-mono text-zinc-500 uppercase tracking-wider">v{uiState.appVersion}</div>
- </div>
-</aside>
diff --git a/packages/ui/src/components/StatusToast.svelte b/packages/ui/src/components/StatusToast.svelte
deleted file mode 100644
index 4c981c7..0000000
--- a/packages/ui/src/components/StatusToast.svelte
+++ /dev/null
@@ -1,42 +0,0 @@
-<script lang="ts">
- import { uiState } from "../stores/ui.svelte";
-</script>
-
-{#if uiState.status !== "Ready"}
- {#key uiState.status}
- <div
- class="absolute top-12 right-12 bg-white/90 dark:bg-zinc-800/90 backdrop-blur border border-zinc-200 dark:border-zinc-600 p-4 rounded-lg shadow-2xl max-w-sm animate-in fade-in slide-in-from-top-4 duration-300 z-50 group"
- >
- <div class="flex justify-between items-start mb-1">
- <div class="text-xs text-zinc-500 dark:text-zinc-400 uppercase font-bold">Status</div>
- <button
- onclick={() => uiState.setStatus("Ready")}
- class="text-zinc-400 hover:text-black dark:text-zinc-500 dark:hover:text-white transition -mt-1 -mr-1 p-1"
- >
- ✕
- </button>
- </div>
- <div class="font-mono text-sm whitespace-pre-wrap mb-2 text-gray-900 dark:text-gray-100">{uiState.status}</div>
- <div class="w-full bg-gray-200 dark:bg-zinc-700/50 h-1 rounded-full overflow-hidden">
- <div
- class="h-full bg-indigo-500 origin-left w-full progress-bar"
- ></div>
- </div>
- </div>
- {/key}
-{/if}
-
-<style>
- .progress-bar {
- animation: progress 5s linear forwards;
- }
-
- @keyframes progress {
- from {
- transform: scaleX(1);
- }
- to {
- transform: scaleX(0);
- }
- }
-</style>
diff --git a/packages/ui/src/components/VersionsView.svelte b/packages/ui/src/components/VersionsView.svelte
deleted file mode 100644
index f1474d9..0000000
--- a/packages/ui/src/components/VersionsView.svelte
+++ /dev/null
@@ -1,511 +0,0 @@
-<script lang="ts">
- import { invoke } from "@tauri-apps/api/core";
- import { listen, type UnlistenFn } from "@tauri-apps/api/event";
- import { gameState } from "../stores/game.svelte";
- import { instancesState } from "../stores/instances.svelte";
- import ModLoaderSelector from "./ModLoaderSelector.svelte";
-
- let searchQuery = $state("");
- let normalizedQuery = $derived(
- searchQuery.trim().toLowerCase().replace(/。/g, ".")
- );
-
- // Filter by version type
- let typeFilter = $state<"all" | "release" | "snapshot" | "installed">("all");
-
- // Installed modded versions with Java version info (Fabric + Forge)
- let installedFabricVersions = $state<Array<{ id: string; javaVersion?: number }>>([]);
- let isLoadingModded = $state(false);
-
- // Load installed modded versions with Java version info (both Fabric and Forge)
- async function loadInstalledModdedVersions() {
- if (!instancesState.activeInstanceId) {
- installedFabricVersions = [];
- isLoadingModded = false;
- return;
- }
- isLoadingModded = true;
- try {
- // Get all installed versions and filter for modded ones (Fabric and Forge)
- const allInstalled = await invoke<Array<{ id: string; type: string }>>(
- "list_installed_versions",
- { instanceId: instancesState.activeInstanceId }
- );
-
- // Filter for Fabric and Forge versions
- const moddedIds = allInstalled
- .filter(v => v.type === "fabric" || v.type === "forge")
- .map(v => v.id);
-
- // Load Java version for each installed modded version
- const versionsWithJava = await Promise.all(
- moddedIds.map(async (id) => {
- try {
- const javaVersion = await invoke<number | null>(
- "get_version_java_version",
- {
- instanceId: instancesState.activeInstanceId!,
- versionId: id,
- }
- );
- return {
- id,
- javaVersion: javaVersion ?? undefined,
- };
- } catch (e) {
- console.error(`Failed to get Java version for ${id}:`, e);
- return { id, javaVersion: undefined };
- }
- })
- );
-
- installedFabricVersions = versionsWithJava;
- } catch (e) {
- console.error("Failed to load installed modded versions:", e);
- } finally {
- isLoadingModded = false;
- }
- }
-
- let versionDeletedUnlisten: UnlistenFn | null = null;
- let downloadCompleteUnlisten: UnlistenFn | null = null;
- let versionInstalledUnlisten: UnlistenFn | null = null;
- let fabricInstalledUnlisten: UnlistenFn | null = null;
- let forgeInstalledUnlisten: UnlistenFn | null = null;
-
- // Load on mount and setup event listeners
- $effect(() => {
- loadInstalledModdedVersions();
- setupEventListeners();
- return () => {
- if (versionDeletedUnlisten) {
- versionDeletedUnlisten();
- }
- if (downloadCompleteUnlisten) {
- downloadCompleteUnlisten();
- }
- if (versionInstalledUnlisten) {
- versionInstalledUnlisten();
- }
- if (fabricInstalledUnlisten) {
- fabricInstalledUnlisten();
- }
- if (forgeInstalledUnlisten) {
- forgeInstalledUnlisten();
- }
- };
- });
-
- async function setupEventListeners() {
- // Refresh versions when a version is deleted
- versionDeletedUnlisten = await listen("version-deleted", async () => {
- await gameState.loadVersions();
- await loadInstalledModdedVersions();
- });
-
- // Refresh versions when a download completes (version installed)
- downloadCompleteUnlisten = await listen("download-complete", async () => {
- await gameState.loadVersions();
- await loadInstalledModdedVersions();
- });
-
- // Refresh when a version is installed
- versionInstalledUnlisten = await listen("version-installed", async () => {
- await gameState.loadVersions();
- await loadInstalledModdedVersions();
- });
-
- // Refresh when Fabric is installed
- fabricInstalledUnlisten = await listen("fabric-installed", async () => {
- await gameState.loadVersions();
- await loadInstalledModdedVersions();
- });
-
- // Refresh when Forge is installed
- forgeInstalledUnlisten = await listen("forge-installed", async () => {
- await gameState.loadVersions();
- await loadInstalledModdedVersions();
- });
- }
-
- // Combined versions list (vanilla + modded)
- let allVersions = $derived(() => {
- const moddedVersions = installedFabricVersions.map((v) => {
- // Determine type based on version ID
- const versionType = v.id.startsWith("fabric-loader-") ? "fabric" :
- v.id.includes("-forge-") ? "forge" : "fabric";
- return {
- id: v.id,
- type: versionType,
- url: "",
- time: "",
- releaseTime: new Date().toISOString(),
- javaVersion: v.javaVersion,
- isInstalled: true, // Modded versions in the list are always installed
- };
- });
- return [...moddedVersions, ...gameState.versions];
- });
-
- let filteredVersions = $derived(() => {
- let versions = allVersions();
-
- // Apply type filter
- if (typeFilter === "release") {
- versions = versions.filter((v) => v.type === "release");
- } else if (typeFilter === "snapshot") {
- versions = versions.filter((v) => v.type === "snapshot");
- } else if (typeFilter === "installed") {
- versions = versions.filter((v) => v.isInstalled === true);
- }
-
- // Apply search filter
- if (normalizedQuery.length > 0) {
- versions = versions.filter((v) =>
- v.id.toLowerCase().includes(normalizedQuery)
- );
- }
-
- return versions;
- });
-
- function getVersionBadge(type: string) {
- switch (type) {
- case "release":
- return { text: "Release", class: "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-300 dark:border-emerald-500/30" };
- case "snapshot":
- return { text: "Snapshot", class: "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/20 dark:text-amber-300 dark:border-amber-500/30" };
- case "fabric":
- return { text: "Fabric", class: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:border-indigo-500/30" };
- case "forge":
- return { text: "Forge", class: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-300 dark:border-orange-500/30" };
- case "modpack":
- return { text: "Modpack", class: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-500/20 dark:text-purple-300 dark:border-purple-500/30" };
- default:
- return { text: type, class: "bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-zinc-500/20 dark:text-zinc-300 dark:border-zinc-500/30" };
- }
- }
-
- function handleModLoaderInstall(versionId: string) {
- // Refresh the installed versions list
- loadInstalledModdedVersions();
- // Refresh vanilla versions to update isInstalled status
- gameState.loadVersions();
- // Select the newly installed version
- gameState.selectedVersion = versionId;
- }
-
- // Delete confirmation dialog state
- let showDeleteDialog = $state(false);
- let versionToDelete = $state<string | null>(null);
-
- // Show delete confirmation dialog
- function showDeleteConfirmation(versionId: string, event: MouseEvent) {
- event.stopPropagation(); // Prevent version selection
- versionToDelete = versionId;
- showDeleteDialog = true;
- }
-
- // Cancel delete
- function cancelDelete() {
- showDeleteDialog = false;
- versionToDelete = null;
- }
-
- // Confirm and delete version
- async function confirmDelete() {
- if (!versionToDelete) return;
-
- try {
- await invoke("delete_version", {
- instanceId: instancesState.activeInstanceId,
- versionId: versionToDelete
- });
- // Clear selection if deleted version was selected
- if (gameState.selectedVersion === versionToDelete) {
- gameState.selectedVersion = "";
- }
- // Close dialog
- showDeleteDialog = false;
- versionToDelete = null;
- // Versions will be refreshed automatically via event listener
- } catch (e) {
- console.error("Failed to delete version:", e);
- alert(`Failed to delete version: ${e}`);
- // Keep dialog open on error so user can retry
- }
- }
-
- // Version metadata for the selected version
- interface VersionMetadata {
- id: string;
- javaVersion?: number;
- isInstalled: boolean;
- }
-
- let selectedVersionMetadata = $state<VersionMetadata | null>(null);
- let isLoadingMetadata = $state(false);
-
- // Load metadata when version is selected
- async function loadVersionMetadata(versionId: string) {
- if (!versionId) {
- selectedVersionMetadata = null;
- return;
- }
-
- isLoadingMetadata = true;
- try {
- const metadata = await invoke<VersionMetadata>("get_version_metadata", {
- instanceId: instancesState.activeInstanceId,
- versionId,
- });
- selectedVersionMetadata = metadata;
- } catch (e) {
- console.error("Failed to load version metadata:", e);
- selectedVersionMetadata = null;
- } finally {
- isLoadingMetadata = false;
- }
- }
-
- // Watch for selected version changes
- $effect(() => {
- if (gameState.selectedVersion) {
- loadVersionMetadata(gameState.selectedVersion);
- } else {
- selectedVersionMetadata = null;
- }
- });
-
- // Get the base Minecraft version from selected version (for mod loader selector)
- let selectedBaseVersion = $derived(() => {
- const selected = gameState.selectedVersion;
- if (!selected) return "";
-
- // If it's a modded version, extract the base version
- if (selected.startsWith("fabric-loader-")) {
- // Format: fabric-loader-X.X.X-1.20.4
- const parts = selected.split("-");
- return parts[parts.length - 1];
- }
- if (selected.includes("-forge-")) {
- // Format: 1.20.4-forge-49.0.38
- return selected.split("-forge-")[0];
- }
-
- // Check if it's a valid vanilla version
- const version = gameState.versions.find((v) => v.id === selected);
- return version ? selected : "";
- });
-</script>
-
-<div class="h-full flex flex-col p-6 overflow-hidden">
- <div class="flex items-center justify-between mb-6">
- <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/60">Version Manager</h2>
- <div class="text-sm dark:text-white/40 text-black/50">Select a version to play or modify</div>
- </div>
-
- <div class="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 overflow-hidden">
- <!-- Left: Version List -->
- <div class="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
- <!-- Search and Filters (Glass Bar) -->
- <div class="flex gap-3">
- <div class="relative flex-1">
- <span class="absolute left-3 top-1/2 -translate-y-1/2 dark:text-white/30 text-black/30">🔍</span>
- <input
- type="text"
- placeholder="Search versions..."
- class="w-full pl-9 pr-4 py-3 bg-white/60 dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-xl dark:text-white text-gray-900 placeholder-black/30 dark:placeholder-white/30 focus:outline-none focus:border-indigo-500/50 dark:focus:bg-black/40 focus:bg-white/80 transition-all backdrop-blur-sm"
- bind:value={searchQuery}
- />
- </div>
- </div>
-
- <!-- Type Filter Tabs (Glass Caps) -->
- <div class="flex p-1 bg-white/60 dark:bg-black/20 rounded-xl border border-black/5 dark:border-white/5">
- {#each ['all', 'release', 'snapshot', 'installed'] as filter}
- <button
- class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 capitalize
- {typeFilter === filter
- ? 'bg-white shadow-sm border border-black/5 dark:bg-white/10 dark:text-white dark:shadow-lg dark:border-white/10 text-black'
- : 'text-black/40 dark:text-white/40 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
- onclick={() => (typeFilter = filter as any)}
- >
- {filter}
- </button>
- {/each}
- </div>
-
- <!-- Version List SCROLL -->
- <div class="flex-1 overflow-y-auto pr-2 space-y-2 custom-scrollbar">
- {#if gameState.versions.length === 0}
- <div class="flex items-center justify-center h-40 dark:text-white/30 text-black/30 italic animate-pulse">
- Fetching manifest...
- </div>
- {:else if filteredVersions().length === 0}
- <div class="flex flex-col items-center justify-center h-40 dark:text-white/30 text-black/30 gap-2">
- <span class="text-2xl">👻</span>
- <span>No matching versions found</span>
- </div>
- {:else}
- {#each filteredVersions() as version}
- {@const badge = getVersionBadge(version.type)}
- {@const isSelected = gameState.selectedVersion === version.id}
- <button
- class="w-full group flex items-center justify-between p-4 rounded-xl text-left border transition-all duration-200 relative overflow-hidden
- {isSelected
- ? 'bg-indigo-50 border-indigo-200 dark:bg-indigo-600/20 dark:border-indigo-500/50 shadow-[0_0_20px_rgba(99,102,241,0.2)]'
- : 'bg-white/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/10 dark:hover:border-white/10 hover:translate-x-1'}"
- onclick={() => (gameState.selectedVersion = version.id)}
- >
- <!-- Selection Glow -->
- {#if isSelected}
- <div class="absolute inset-0 bg-gradient-to-r from-indigo-500/10 to-transparent pointer-events-none"></div>
- {/if}
-
- <div class="relative z-10 flex items-center gap-4 flex-1">
- <span
- class="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border {badge.class}"
- >
- {badge.text}
- </span>
- <div class="flex-1">
- <div class="font-bold font-mono text-lg tracking-tight {isSelected ? 'text-black dark:text-white' : 'text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white'}">
- {version.id}
- </div>
- <div class="flex items-center gap-2 mt-0.5">
- {#if version.releaseTime && version.type !== "fabric" && version.type !== "forge"}
- <div class="text-xs dark:text-white/30 text-black/30">
- {new Date(version.releaseTime).toLocaleDateString()}
- </div>
- {/if}
- {#if version.javaVersion}
- <div class="flex items-center gap-1 text-xs dark:text-white/40 text-black/40">
- <span class="opacity-60">☕</span>
- <span class="font-medium">Java {version.javaVersion}</span>
- </div>
- {/if}
- </div>
- </div>
- </div>
-
- <div class="relative z-10 flex items-center gap-2">
- {#if version.isInstalled === true}
- <button
- onclick={(e) => showDeleteConfirmation(version.id, e)}
- class="p-2 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-500/10 dark:hover:bg-red-500/20 transition-colors opacity-0 group-hover:opacity-100"
- title="Delete version"
- >
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M3 6h18"></path>
- <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
- <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
- </svg>
- </button>
- {/if}
- {#if isSelected}
- <div class="text-indigo-500 dark:text-indigo-400">
- <span class="text-lg">Selected</span>
- </div>
- {/if}
- </div>
- </button>
- {/each}
- {/if}
- </div>
- </div>
-
- <!-- Right: Mod Loader Panel -->
- <div class="flex flex-col gap-4">
- <!-- Selected Version Info Card -->
- <div class="bg-gradient-to-br from-white/40 to-white/20 dark:from-white/10 dark:to-white/5 p-6 rounded-2xl border border-black/5 dark:border-white/10 backdrop-blur-md relative overflow-hidden group">
- <div class="absolute top-0 right-0 p-8 bg-indigo-500/20 blur-[60px] rounded-full group-hover:bg-indigo-500/30 transition-colors"></div>
-
- <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-2 relative z-10">Current Selection</h3>
- {#if gameState.selectedVersion}
- <p class="font-mono text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/70 relative z-10 truncate mb-4">
- {gameState.selectedVersion}
- </p>
-
- <!-- Version Metadata -->
- {#if isLoadingMetadata}
- <div class="space-y-3 relative z-10">
- <div class="animate-pulse space-y-2">
- <div class="h-4 bg-black/10 dark:bg-white/10 rounded w-3/4"></div>
- <div class="h-4 bg-black/10 dark:bg-white/10 rounded w-1/2"></div>
- </div>
- </div>
- {:else if selectedVersionMetadata}
- <div class="space-y-3 relative z-10">
- <!-- Java Version -->
- {#if selectedVersionMetadata.javaVersion}
- <div>
- <div class="text-[10px] font-bold uppercase tracking-wider dark:text-white/40 text-black/40 mb-1">Java Version</div>
- <div class="flex items-center gap-2">
- <span class="text-lg opacity-60">☕</span>
- <span class="text-sm dark:text-white text-black font-medium">
- Java {selectedVersionMetadata.javaVersion}
- </span>
- </div>
- </div>
- {/if}
-
- <!-- Installation Status -->
- <div>
- <div class="text-[10px] font-bold uppercase tracking-wider dark:text-white/40 text-black/40 mb-1">Status</div>
- <div class="flex items-center gap-2">
- {#if selectedVersionMetadata.isInstalled === true}
- <span class="px-2 py-0.5 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-[10px] font-bold rounded border border-emerald-500/30">
- Installed
- </span>
- {:else if selectedVersionMetadata.isInstalled === false}
- <span class="px-2 py-0.5 bg-zinc-500/20 text-zinc-600 dark:text-zinc-400 text-[10px] font-bold rounded border border-zinc-500/30">
- Not Installed
- </span>
- {/if}
- </div>
- </div>
- </div>
- {/if}
- {:else}
- <p class="dark:text-white/20 text-black/20 italic relative z-10">None selected</p>
- {/if}
- </div>
-
- <!-- Mod Loader Selector Card -->
- <div class="bg-white/60 dark:bg-black/20 p-4 rounded-2xl border border-black/5 dark:border-white/5 backdrop-blur-sm flex-1 flex flex-col">
- <ModLoaderSelector
- selectedGameVersion={selectedBaseVersion()}
- onInstall={handleModLoaderInstall}
- />
- </div>
-
- </div>
- </div>
-
- <!-- Delete Version Confirmation Dialog -->
- {#if showDeleteDialog && versionToDelete}
- <div class="fixed inset-0 z-[200] bg-black/70 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
- <div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200">
- <h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Delete Version</h3>
- <p class="text-zinc-600 dark:text-zinc-400 text-sm mb-6">
- Are you sure you want to delete version <span class="text-gray-900 dark:text-white font-mono font-medium">{versionToDelete}</span>? This action cannot be undone.
- </p>
- <div class="flex gap-3 justify-end">
- <button
- onclick={cancelDelete}
- class="px-4 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-colors"
- >
- Cancel
- </button>
- <button
- onclick={confirmDelete}
- class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors"
- >
- Delete
- </button>
- </div>
- </div>
- </div>
- {/if}
-</div>
diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx
new file mode 100644
index 0000000..32eb852
--- /dev/null
+++ b/packages/ui/src/components/bottom-bar.tsx
@@ -0,0 +1,231 @@
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { Play, User } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { listInstalledVersions, startGame } from "@/client";
+import { cn } from "@/lib/utils";
+import { useAuthStore } from "@/models/auth";
+import { useInstancesStore } from "@/models/instances";
+import { useGameStore } from "@/stores/game-store";
+import { LoginModal } from "./login-modal";
+import { Button } from "./ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./ui/select";
+
+interface InstalledVersion {
+ id: string;
+ type: string;
+}
+
+export function BottomBar() {
+ const authStore = useAuthStore();
+ const gameStore = useGameStore();
+ const instancesStore = useInstancesStore();
+
+ const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
+ const [installedVersions, setInstalledVersions] = useState<
+ InstalledVersion[]
+ >([]);
+ const [isLoadingVersions, setIsLoadingVersions] = useState(true);
+ const [showLoginModal, setShowLoginModal] = useState(false);
+
+ const loadInstalledVersions = useCallback(async () => {
+ if (!instancesStore.activeInstance) {
+ setInstalledVersions([]);
+ setIsLoadingVersions(false);
+ return;
+ }
+
+ setIsLoadingVersions(true);
+ try {
+ const versions = await listInstalledVersions(
+ instancesStore.activeInstance.id,
+ );
+
+ const installed = versions || [];
+ setInstalledVersions(installed);
+
+ // If no version is selected but we have installed versions, select the first one
+ if (!gameStore.selectedVersion && installed.length > 0) {
+ gameStore.setSelectedVersion(installed[0].id);
+ }
+ } catch (error) {
+ console.error("Failed to load installed versions:", error);
+ } finally {
+ setIsLoadingVersions(false);
+ }
+ }, [
+ instancesStore.activeInstance,
+ gameStore.selectedVersion,
+ gameStore.setSelectedVersion,
+ ]);
+
+ useEffect(() => {
+ loadInstalledVersions();
+
+ // Listen for backend events that should refresh installed versions.
+ let unlistenDownload: UnlistenFn | null = null;
+ let unlistenVersionDeleted: UnlistenFn | null = null;
+
+ (async () => {
+ try {
+ unlistenDownload = await listen("download-complete", () => {
+ loadInstalledVersions();
+ });
+ } catch (err) {
+ // best-effort: do not break UI if listening fails
+ // eslint-disable-next-line no-console
+ console.warn("Failed to attach download-complete listener:", err);
+ }
+
+ try {
+ unlistenVersionDeleted = await listen("version-deleted", () => {
+ loadInstalledVersions();
+ });
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn("Failed to attach version-deleted listener:", err);
+ }
+ })();
+
+ return () => {
+ try {
+ if (unlistenDownload) unlistenDownload();
+ } catch {
+ // ignore
+ }
+ try {
+ if (unlistenVersionDeleted) unlistenVersionDeleted();
+ } catch {
+ // ignore
+ }
+ };
+ }, [loadInstalledVersions]);
+
+ const handleStartGame = async () => {
+ if (!selectedVersion) {
+ toast.info("Please select a version!");
+ return;
+ }
+
+ if (!instancesStore.activeInstance) {
+ toast.info("Please select an instance first!");
+ return;
+ }
+ // await gameStore.startGame(
+ // authStore.currentAccount,
+ // authStore.openLoginModal,
+ // instancesStore.activeInstanceId,
+ // uiStore.setView,
+ // );
+ await startGame(instancesStore.activeInstance?.id, selectedVersion);
+ };
+
+ const getVersionTypeColor = (type: string) => {
+ switch (type) {
+ case "release":
+ return "bg-emerald-500";
+ case "snapshot":
+ return "bg-amber-500";
+ case "old_beta":
+ return "bg-rose-500";
+ case "old_alpha":
+ return "bg-violet-500";
+ default:
+ return "bg-gray-500";
+ }
+ };
+
+ const versionOptions = useMemo(
+ () =>
+ installedVersions.map((v) => ({
+ label: `${v.id}${v.type !== "release" ? ` (${v.type})` : ""}`,
+ value: v.id,
+ type: v.type,
+ })),
+ [installedVersions],
+ );
+
+ return (
+ <div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/30 via-transparent to-transparent p-4 z-10">
+ <div className="max-w-7xl mx-auto">
+ <div className="flex items-center justify-between bg-white/5 dark:bg-black/20 backdrop-blur-xl border border-white/10 dark:border-white/5 p-3 shadow-lg">
+ <div className="flex items-center gap-4">
+ <div className="flex flex-col">
+ <span className="text-xs font-mono text-zinc-400 uppercase tracking-wider">
+ Active Instance
+ </span>
+ <span className="text-sm font-medium text-white">
+ {instancesStore.activeInstance?.name || "No instance selected"}
+ </span>
+ </div>
+
+ <Select
+ items={versionOptions}
+ onValueChange={setSelectedVersion}
+ disabled={isLoadingVersions}
+ >
+ <SelectTrigger className="max-w-48">
+ <SelectValue
+ placeholder={
+ isLoadingVersions
+ ? "Loading versions..."
+ : "Please select a version"
+ }
+ />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {versionOptions.map((item) => (
+ <SelectItem
+ key={item.value}
+ value={item.value}
+ className={getVersionTypeColor(item.type)}
+ >
+ {item.label}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="flex items-center gap-3">
+ {authStore.account ? (
+ <Button
+ className={cn(
+ "px-4 py-2 shadow-xl",
+ "bg-emerald-600! hover:bg-emerald-500!",
+ )}
+ size="lg"
+ onClick={handleStartGame}
+ >
+ <Play />
+ Start
+ </Button>
+ ) : (
+ <Button
+ className="px-4 py-2"
+ size="lg"
+ onClick={() => setShowLoginModal(true)}
+ >
+ <User /> Login
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <LoginModal
+ open={showLoginModal}
+ onOpenChange={() => setShowLoginModal(false)}
+ />
+ </div>
+ );
+}
diff --git a/packages/ui/src/components/config-editor.tsx b/packages/ui/src/components/config-editor.tsx
new file mode 100644
index 0000000..129b8f7
--- /dev/null
+++ b/packages/ui/src/components/config-editor.tsx
@@ -0,0 +1,111 @@
+import type React from "react";
+import { useEffect, useState } from "react";
+import { type ZodType, z } from "zod";
+import { useSettingsStore } from "@/models/settings";
+import type { LauncherConfig } from "@/types";
+import { Button } from "./ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "./ui/dialog";
+import { FieldError } from "./ui/field";
+import { Spinner } from "./ui/spinner";
+import { Textarea } from "./ui/textarea";
+
+const launcherConfigSchema: ZodType<LauncherConfig> = z.object({
+ minMemory: z.number(),
+ maxMemory: z.number(),
+ javaPath: z.string(),
+ width: z.number(),
+ height: z.number(),
+ downloadThreads: z.number(),
+ customBackgroundPath: z.string().nullable(),
+ enableGpuAcceleration: z.boolean(),
+ enableVisualEffects: z.boolean(),
+ activeEffect: z.string(),
+ theme: z.string(),
+ logUploadService: z.string(),
+ pastebinApiKey: z.string().nullable(),
+ assistant: z.any(), // TODO: AssistantConfig schema
+ useSharedCaches: z.boolean(),
+ keepLegacyPerInstanceStorage: z.boolean(),
+ featureFlags: z.any(), // TODO: FeatureFlags schema
+});
+
+export interface ConfigEditorProps
+ extends Omit<React.ComponentPropsWithoutRef<typeof Dialog>, "onOpenChange"> {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function ConfigEditor({ onOpenChange, ...props }: ConfigEditorProps) {
+ const settings = useSettingsStore();
+
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
+ const [rawConfigContent, setRawConfigContent] = useState(
+ JSON.stringify(settings.config, null, 2),
+ );
+ const [isSaving, setIsSaving] = useState(false);
+
+ useEffect(() => {
+ setRawConfigContent(JSON.stringify(settings.config, null, 2));
+ }, [settings.config]);
+
+ const handleSave = async () => {
+ setIsSaving(true);
+ setErrorMessage(null);
+ try {
+ const validatedConfig = launcherConfigSchema.parse(
+ JSON.parse(rawConfigContent),
+ );
+ settings.config = validatedConfig;
+ await settings.save();
+ onOpenChange?.(false);
+ } catch (error) {
+ setErrorMessage(error instanceof Error ? error.message : String(error));
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+ <Dialog onOpenChange={onOpenChange} {...props}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Edit Configuration</DialogTitle>
+ <DialogDescription>
+ Edit the raw JSON configuration file.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Textarea
+ value={rawConfigContent}
+ onChange={(e) => setRawConfigContent(e.target.value)}
+ className="font-mono text-sm h-100 resize-none"
+ spellCheck={false}
+ aria-invalid={!!errorMessage}
+ />
+
+ {errorMessage && <FieldError errors={[{ message: errorMessage }]} />}
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange?.(false)}
+ disabled={isSaving}
+ >
+ Cancel
+ </Button>
+ <Button onClick={handleSave} disabled={isSaving}>
+ {isSaving && <Spinner />}
+ Save Changes
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/packages/ui/src/components/download-monitor.tsx b/packages/ui/src/components/download-monitor.tsx
new file mode 100644
index 0000000..f3902d9
--- /dev/null
+++ b/packages/ui/src/components/download-monitor.tsx
@@ -0,0 +1,62 @@
+import { X } from "lucide-react";
+import { useState } from "react";
+
+export function DownloadMonitor() {
+ const [isVisible, setIsVisible] = useState(true);
+
+ if (!isVisible) return null;
+
+ return (
+ <div className="bg-zinc-900/80 backdrop-blur-md border border-zinc-700 rounded-lg shadow-2xl overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between px-4 py-3 bg-zinc-800/50 border-b border-zinc-700">
+ <div className="flex items-center gap-2">
+ <div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
+ <span className="text-sm font-medium text-white">Downloads</span>
+ </div>
+ <button
+ type="button"
+ onClick={() => setIsVisible(false)}
+ className="text-zinc-400 hover:text-white transition-colors p-1"
+ >
+ <X size={16} />
+ </button>
+ </div>
+
+ {/* Content */}
+ <div className="p-4">
+ <div className="space-y-3">
+ {/* Download Item */}
+ <div className="space-y-1">
+ <div className="flex justify-between text-xs">
+ <span className="text-zinc-300">Minecraft 1.20.4</span>
+ <span className="text-zinc-400">65%</span>
+ </div>
+ <div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
+ <div
+ className="h-full bg-emerald-500 rounded-full transition-all duration-300"
+ style={{ width: "65%" }}
+ ></div>
+ </div>
+ <div className="flex justify-between text-[10px] text-zinc-500">
+ <span>142 MB / 218 MB</span>
+ <span>2.1 MB/s • 36s remaining</span>
+ </div>
+ </div>
+
+ {/* Download Item */}
+ <div className="space-y-1">
+ <div className="flex justify-between text-xs">
+ <span className="text-zinc-300">Java 17</span>
+ <span className="text-zinc-400">100%</span>
+ </div>
+ <div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
+ <div className="h-full bg-emerald-500 rounded-full"></div>
+ </div>
+ <div className="text-[10px] text-emerald-400">Completed</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui/src/components/game-console.tsx b/packages/ui/src/components/game-console.tsx
new file mode 100644
index 0000000..6980c8c
--- /dev/null
+++ b/packages/ui/src/components/game-console.tsx
@@ -0,0 +1,290 @@
+import { Copy, Download, Filter, Search, Trash2, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { useLogsStore } from "@/stores/logs-store";
+import { useUIStore } from "@/stores/ui-store";
+
+export function GameConsole() {
+ const uiStore = useUIStore();
+ const logsStore = useLogsStore();
+
+ const [searchTerm, setSearchTerm] = useState("");
+ const [selectedLevels, setSelectedLevels] = useState<Set<string>>(
+ new Set(["info", "warn", "error", "debug", "fatal"]),
+ );
+ const [autoScroll, setAutoScroll] = useState(true);
+ const consoleEndRef = useRef<HTMLDivElement>(null);
+ const logsContainerRef = useRef<HTMLDivElement>(null);
+
+ const levelColors: Record<string, string> = {
+ info: "text-blue-400",
+ warn: "text-amber-400",
+ error: "text-red-400",
+ debug: "text-purple-400",
+ fatal: "text-rose-400",
+ };
+
+ const levelBgColors: Record<string, string> = {
+ info: "bg-blue-400/10",
+ warn: "bg-amber-400/10",
+ error: "bg-red-400/10",
+ debug: "bg-purple-400/10",
+ fatal: "bg-rose-400/10",
+ };
+
+ // Filter logs based on search term and selected levels
+ const filteredLogs = logsStore.logs.filter((log) => {
+ const matchesSearch =
+ searchTerm === "" ||
+ log.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ log.source.toLowerCase().includes(searchTerm.toLowerCase());
+
+ const matchesLevel = selectedLevels.has(log.level);
+
+ return matchesSearch && matchesLevel;
+ });
+
+ // Auto-scroll to bottom when new logs arrive or autoScroll is enabled
+ useEffect(() => {
+ if (autoScroll && consoleEndRef.current && filteredLogs.length > 0) {
+ consoleEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [filteredLogs, autoScroll]);
+
+ // Handle keyboard shortcuts
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Ctrl/Cmd + K to focus search
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
+ e.preventDefault();
+ // Focus search input
+ const searchInput = document.querySelector(
+ 'input[type="text"]',
+ ) as HTMLInputElement;
+ if (searchInput) searchInput.focus();
+ }
+ // Escape to close console
+ if (e.key === "Escape") {
+ uiStore.toggleConsole();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [uiStore.toggleConsole]);
+
+ const toggleLevel = (level: string) => {
+ const newLevels = new Set(selectedLevels);
+ if (newLevels.has(level)) {
+ newLevels.delete(level);
+ } else {
+ newLevels.add(level);
+ }
+ setSelectedLevels(newLevels);
+ };
+
+ const handleCopyAll = () => {
+ const logsText = logsStore.exportLogs(filteredLogs);
+ navigator.clipboard.writeText(logsText);
+ };
+
+ const handleExport = () => {
+ const logsText = logsStore.exportLogs(filteredLogs);
+ const blob = new Blob([logsText], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `dropout_logs_${new Date().toISOString().replace(/[:.]/g, "-")}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ const handleClear = () => {
+ logsStore.clear();
+ };
+
+ return (
+ <>
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 border-b border-zinc-700 bg-[#252526]">
+ <div className="flex items-center gap-3">
+ <h2 className="text-lg font-bold text-white">Game Console</h2>
+ <div className="flex items-center gap-1">
+ <span className="text-xs text-zinc-400">Logs:</span>
+ <span className="text-xs font-medium text-emerald-400">
+ {filteredLogs.length}
+ </span>
+ <span className="text-xs text-zinc-400">/</span>
+ <span className="text-xs text-zinc-400">
+ {logsStore.logs.length}
+ </span>
+ </div>
+ </div>
+ <button
+ type="button"
+ onClick={() => uiStore.toggleConsole()}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ >
+ <X size={20} />
+ </button>
+ </div>
+
+ {/* Toolbar */}
+ <div className="flex items-center gap-3 p-3 border-b border-zinc-700 bg-[#2D2D30]">
+ {/* Search */}
+ <div className="relative flex-1">
+ <Search
+ className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-500"
+ size={16}
+ />
+ <input
+ type="text"
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ placeholder="Search logs..."
+ className="w-full pl-10 pr-4 py-2 bg-[#3E3E42] border border-zinc-600 rounded text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
+ />
+ {searchTerm && (
+ <button
+ type="button"
+ onClick={() => setSearchTerm("")}
+ className="absolute right-3 top-1/2 transform -translate-y-1/2 text-zinc-400 hover:text-white"
+ >
+ ×
+ </button>
+ )}
+ </div>
+
+ {/* Level Filters */}
+ <div className="flex items-center gap-1">
+ {Object.entries(levelColors).map(([level, colorClass]) => (
+ <button
+ type="button"
+ key={level}
+ onClick={() => toggleLevel(level)}
+ className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
+ selectedLevels.has(level)
+ ? `${levelBgColors[level]} ${colorClass}`
+ : "bg-[#3E3E42] text-zinc-400 hover:text-white"
+ }`}
+ >
+ {level.toUpperCase()}
+ </button>
+ ))}
+ </div>
+
+ {/* Actions */}
+ <div className="flex items-center gap-1">
+ <button
+ type="button"
+ onClick={handleCopyAll}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ title="Copy all logs"
+ >
+ <Copy size={16} />
+ </button>
+ <button
+ type="button"
+ onClick={handleExport}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ title="Export logs"
+ >
+ <Download size={16} />
+ </button>
+ <button
+ type="button"
+ onClick={handleClear}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ title="Clear logs"
+ >
+ <Trash2 size={16} />
+ </button>
+ </div>
+
+ {/* Auto-scroll Toggle */}
+ <div className="flex items-center gap-2 pl-2 border-l border-zinc-700">
+ <label className="inline-flex items-center cursor-pointer">
+ <input
+ type="checkbox"
+ checked={autoScroll}
+ onChange={(e) => setAutoScroll(e.target.checked)}
+ className="sr-only peer"
+ />
+ <div className="relative w-9 h-5 bg-zinc-700 peer-focus:outline-none peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
+ <span className="ml-2 text-xs text-zinc-400">Auto-scroll</span>
+ </label>
+ </div>
+ </div>
+
+ {/* Logs Container */}
+ <div
+ ref={logsContainerRef}
+ className="flex-1 overflow-y-auto font-mono text-sm bg-[#1E1E1E]"
+ style={{ fontFamily: "'Cascadia Code', 'Consolas', monospace" }}
+ >
+ {filteredLogs.length === 0 ? (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center text-zinc-500">
+ <Filter className="mx-auto mb-2" size={24} />
+ <p>No logs match the current filters</p>
+ </div>
+ </div>
+ ) : (
+ <div className="p-4 space-y-1">
+ {filteredLogs.map((log) => (
+ <div
+ key={log.id}
+ className="group hover:bg-white/5 p-2 rounded transition-colors"
+ >
+ <div className="flex items-start gap-3">
+ <div
+ className={`px-2 py-0.5 rounded text-xs font-medium ${levelBgColors[log.level]} ${levelColors[log.level]}`}
+ >
+ {log.level.toUpperCase()}
+ </div>
+ <div className="text-zinc-400 text-xs shrink-0">
+ {log.timestamp}
+ </div>
+ <div className="text-amber-300 text-xs shrink-0">
+ [{log.source}]
+ </div>
+ <div className="text-gray-300 flex-1">{log.message}</div>
+ </div>
+ </div>
+ ))}
+ <div ref={consoleEndRef} />
+ </div>
+ )}
+ </div>
+
+ {/* Footer */}
+ <div className="flex items-center justify-between p-3 border-t border-zinc-700 bg-[#252526] text-xs text-zinc-400">
+ <div className="flex items-center gap-4">
+ <div>
+ <span>Total: </span>
+ <span className="text-white">{logsStore.logs.length}</span>
+ <span> | Filtered: </span>
+ <span className="text-emerald-400">{filteredLogs.length}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <kbd className="px-1.5 py-0.5 bg-[#3E3E42] rounded text-xs">
+ Ctrl+K
+ </kbd>
+ <span>to search</span>
+ </div>
+ </div>
+ <div>
+ <span>Updated: </span>
+ <span>
+ {new Date().toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ })}
+ </span>
+ </div>
+ </div>
+ </>
+ );
+}
diff --git a/packages/ui/src/components/instance-creation-modal.tsx b/packages/ui/src/components/instance-creation-modal.tsx
new file mode 100644
index 0000000..8a2b1b4
--- /dev/null
+++ b/packages/ui/src/components/instance-creation-modal.tsx
@@ -0,0 +1,552 @@
+import { invoke } from "@tauri-apps/api/core";
+import { Loader2, Search } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { useInstancesStore } from "@/models/instances";
+import { useGameStore } from "@/stores/game-store";
+import type { Version } from "@/types/bindings/manifest";
+import type { FabricLoaderEntry } from "../types/bindings/fabric";
+import type { ForgeVersion as ForgeVersionEntry } from "../types/bindings/forge";
+import type { Instance } from "../types/bindings/instance";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function InstanceCreationModal({ open, onOpenChange }: Props) {
+ const gameStore = useGameStore();
+ const instancesStore = useInstancesStore();
+
+ // Steps: 1 = name, 2 = version, 3 = mod loader
+ const [step, setStep] = useState<number>(1);
+
+ // Step 1
+ const [instanceName, setInstanceName] = useState<string>("");
+
+ // Step 2
+ const [versionSearch, setVersionSearch] = useState<string>("");
+ const [versionFilter, setVersionFilter] = useState<
+ "all" | "release" | "snapshot"
+ >("release");
+ const [selectedVersionUI, setSelectedVersionUI] = useState<Version | null>(
+ null,
+ );
+
+ // Step 3
+ const [modLoaderType, setModLoaderType] = useState<
+ "vanilla" | "fabric" | "forge"
+ >("vanilla");
+ const [fabricLoaders, setFabricLoaders] = useState<FabricLoaderEntry[]>([]);
+ const [forgeVersions, setForgeVersions] = useState<ForgeVersionEntry[]>([]);
+ const [selectedFabricLoader, setSelectedFabricLoader] = useState<string>("");
+ const [selectedForgeLoader, setSelectedForgeLoader] = useState<string>("");
+ const [loadingLoaders, setLoadingLoaders] = useState(false);
+
+ const loadModLoaders = useCallback(async () => {
+ if (!selectedVersionUI) return;
+ setLoadingLoaders(true);
+ setFabricLoaders([]);
+ setForgeVersions([]);
+ try {
+ if (modLoaderType === "fabric") {
+ const loaders = await invoke<FabricLoaderEntry[]>(
+ "get_fabric_loaders_for_version",
+ {
+ gameVersion: selectedVersionUI.id,
+ },
+ );
+ setFabricLoaders(loaders || []);
+ if (loaders && loaders.length > 0) {
+ setSelectedFabricLoader(loaders[0].loader.version);
+ } else {
+ setSelectedFabricLoader("");
+ }
+ } else if (modLoaderType === "forge") {
+ const versions = await invoke<ForgeVersionEntry[]>(
+ "get_forge_versions_for_game",
+ {
+ gameVersion: selectedVersionUI.id,
+ },
+ );
+ setForgeVersions(versions || []);
+ if (versions && versions.length > 0) {
+ // Binding `ForgeVersion` uses `version` (not `id`) — use `.version` here.
+ setSelectedForgeLoader(versions[0].version);
+ } else {
+ setSelectedForgeLoader("");
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load mod loaders:", e);
+ toast.error("Failed to fetch mod loader versions");
+ } finally {
+ setLoadingLoaders(false);
+ }
+ }, [modLoaderType, selectedVersionUI]);
+
+ // When entering step 3 and a base version exists, fetch loaders if needed
+ useEffect(() => {
+ if (step === 3 && modLoaderType !== "vanilla" && selectedVersionUI) {
+ loadModLoaders();
+ }
+ }, [step, modLoaderType, selectedVersionUI, loadModLoaders]);
+
+ // Creating state
+ const [creating, setCreating] = useState(false);
+ const [errorMessage, setErrorMessage] = useState<string>("");
+
+ // Derived filtered versions
+ const filteredVersions = useMemo(() => {
+ const all = gameStore.versions || [];
+ let list = all.slice();
+ if (versionFilter !== "all") {
+ list = list.filter((v) => v.type === versionFilter);
+ }
+ if (versionSearch.trim()) {
+ const q = versionSearch.trim().toLowerCase().replace(/。/g, ".");
+ list = list.filter((v) => v.id.toLowerCase().includes(q));
+ }
+ return list;
+ }, [gameStore.versions, versionFilter, versionSearch]);
+
+ // Reset when opened/closed
+ useEffect(() => {
+ if (open) {
+ // ensure versions are loaded
+ gameStore.loadVersions();
+ setStep(1);
+ setInstanceName("");
+ setVersionSearch("");
+ setVersionFilter("release");
+ setSelectedVersionUI(null);
+ setModLoaderType("vanilla");
+ setFabricLoaders([]);
+ setForgeVersions([]);
+ setSelectedFabricLoader("");
+ setSelectedForgeLoader("");
+ setErrorMessage("");
+ setCreating(false);
+ }
+ }, [open, gameStore.loadVersions]);
+
+ function validateStep1(): boolean {
+ if (!instanceName.trim()) {
+ setErrorMessage("Please enter an instance name");
+ return false;
+ }
+ setErrorMessage("");
+ return true;
+ }
+
+ function validateStep2(): boolean {
+ if (!selectedVersionUI) {
+ setErrorMessage("Please select a Minecraft version");
+ return false;
+ }
+ setErrorMessage("");
+ return true;
+ }
+
+ async function handleNext() {
+ setErrorMessage("");
+ if (step === 1) {
+ if (!validateStep1()) return;
+ setStep(2);
+ } else if (step === 2) {
+ if (!validateStep2()) return;
+ setStep(3);
+ }
+ }
+
+ function handleBack() {
+ setErrorMessage("");
+ setStep((s) => Math.max(1, s - 1));
+ }
+
+ async function handleCreate() {
+ if (!validateStep1() || !validateStep2()) return;
+ setCreating(true);
+ setErrorMessage("");
+
+ try {
+ // Step 1: create instance
+ const instance = await invoke<Instance>("create_instance", {
+ name: instanceName.trim(),
+ });
+
+ // If selectedVersion provided, install it
+ if (selectedVersionUI) {
+ try {
+ await invoke("install_version", {
+ instanceId: instance.id,
+ versionId: selectedVersionUI.id,
+ });
+ } catch (err) {
+ console.error("Failed to install base version:", err);
+ // continue - instance created but version install failed
+ toast.error(
+ `Failed to install version ${selectedVersionUI.id}: ${String(err)}`,
+ );
+ }
+ }
+
+ // If mod loader selected, install it
+ if (modLoaderType === "fabric" && selectedFabricLoader) {
+ try {
+ await invoke("install_fabric", {
+ instanceId: instance.id,
+ gameVersion: selectedVersionUI?.id ?? "",
+ loaderVersion: selectedFabricLoader,
+ });
+ } catch (err) {
+ console.error("Failed to install Fabric:", err);
+ toast.error(`Failed to install Fabric: ${String(err)}`);
+ }
+ } else if (modLoaderType === "forge" && selectedForgeLoader) {
+ try {
+ await invoke("install_forge", {
+ instanceId: instance.id,
+ gameVersion: selectedVersionUI?.id ?? "",
+ installerVersion: selectedForgeLoader,
+ });
+ } catch (err) {
+ console.error("Failed to install Forge:", err);
+ toast.error(`Failed to install Forge: ${String(err)}`);
+ }
+ }
+
+ // Refresh instances list
+ await instancesStore.refresh();
+
+ toast.success("Instance created successfully");
+ onOpenChange(false);
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ setErrorMessage(String(e));
+ toast.error(`Failed to create instance: ${e}`);
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ // UI pieces
+ const StepIndicator = () => (
+ <div className="flex gap-2 w-full">
+ <div
+ className={`flex-1 h-1 rounded ${step >= 1 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ <div
+ className={`flex-1 h-1 rounded ${step >= 2 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ <div
+ className={`flex-1 h-1 rounded ${step >= 3 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ </div>
+ );
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="w-full max-w-3xl max-h-[90vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Create New Instance</DialogTitle>
+ <DialogDescription>
+ Multi-step wizard — create an instance and optionally install a
+ version or mod loader.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="px-6">
+ <div className="pt-4 pb-6">
+ <StepIndicator />
+ </div>
+
+ {/* Step 1 - Name */}
+ {step === 1 && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="instance-name"
+ className="block text-sm font-medium mb-2"
+ >
+ Instance Name
+ </label>
+ <Input
+ id="instance-name"
+ placeholder="My Minecraft Instance"
+ value={instanceName}
+ onChange={(e) => setInstanceName(e.target.value)}
+ disabled={creating}
+ />
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Give your instance a memorable name.
+ </p>
+ </div>
+ )}
+
+ {/* Step 2 - Version selection */}
+ {step === 2 && (
+ <div className="space-y-4">
+ <div className="flex gap-3">
+ <div className="relative flex-1">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
+ <Input
+ value={versionSearch}
+ onChange={(e) => setVersionSearch(e.target.value)}
+ placeholder="Search versions..."
+ className="pl-9"
+ />
+ </div>
+
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant={versionFilter === "all" ? "default" : "outline"}
+ onClick={() => setVersionFilter("all")}
+ >
+ All
+ </Button>
+ <Button
+ type="button"
+ variant={
+ versionFilter === "release" ? "default" : "outline"
+ }
+ onClick={() => setVersionFilter("release")}
+ >
+ Release
+ </Button>
+ <Button
+ type="button"
+ variant={
+ versionFilter === "snapshot" ? "default" : "outline"
+ }
+ onClick={() => setVersionFilter("snapshot")}
+ >
+ Snapshot
+ </Button>
+ </div>
+ </div>
+
+ <ScrollArea className="max-h-[36vh]">
+ <div className="space-y-2 py-2">
+ {gameStore.versions.length === 0 ? (
+ <div className="flex items-center justify-center py-8 text-muted-foreground">
+ <Loader2 className="animate-spin mr-2" />
+ Loading versions...
+ </div>
+ ) : filteredVersions.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ No matching versions found
+ </div>
+ ) : (
+ filteredVersions.map((v) => {
+ const isSelected = selectedVersionUI?.id === v.id;
+ return (
+ <button
+ key={v.id}
+ type="button"
+ onClick={() => setSelectedVersionUI(v)}
+ className={`w-full text-left p-3 rounded-lg border transition-colors ${
+ isSelected
+ ? "bg-indigo-50 dark:bg-indigo-600/20 border-indigo-200"
+ : "bg-white/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:bg-white/60"
+ }`}
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-mono font-bold">{v.id}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {v.type}{" "}
+ {v.releaseTime
+ ? ` • ${new Date(v.releaseTime).toLocaleDateString()}`
+ : ""}
+ </div>
+ </div>
+ {v.javaVersion && (
+ <div className="text-sm">
+ Java {v.javaVersion}
+ </div>
+ )}
+ </div>
+ </button>
+ );
+ })
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ )}
+
+ {/* Step 3 - Mod loader */}
+ {step === 3 && (
+ <div className="space-y-4">
+ <div>
+ <div className="text-sm font-medium mb-2">Mod Loader Type</div>
+ <div className="flex gap-3">
+ <Button
+ type="button"
+ variant={
+ modLoaderType === "vanilla" ? "default" : "outline"
+ }
+ onClick={() => setModLoaderType("vanilla")}
+ >
+ Vanilla
+ </Button>
+ <Button
+ type="button"
+ variant={modLoaderType === "fabric" ? "default" : "outline"}
+ onClick={() => setModLoaderType("fabric")}
+ >
+ Fabric
+ </Button>
+ <Button
+ type="button"
+ variant={modLoaderType === "forge" ? "default" : "outline"}
+ onClick={() => setModLoaderType("forge")}
+ >
+ Forge
+ </Button>
+ </div>
+ </div>
+
+ {modLoaderType === "fabric" && (
+ <div>
+ {loadingLoaders ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="animate-spin" />
+ Loading Fabric versions...
+ </div>
+ ) : fabricLoaders.length > 0 ? (
+ <div className="space-y-2">
+ <select
+ value={selectedFabricLoader}
+ onChange={(e) =>
+ setSelectedFabricLoader(e.target.value)
+ }
+ className="w-full px-3 py-2 rounded border bg-transparent"
+ >
+ {fabricLoaders.map((f) => (
+ <option
+ key={f.loader.version}
+ value={f.loader.version}
+ >
+ {f.loader.version}{" "}
+ {f.loader.stable ? "(Stable)" : "(Beta)"}
+ </option>
+ ))}
+ </select>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ No Fabric loaders available for this version
+ </p>
+ )}
+ </div>
+ )}
+
+ {modLoaderType === "forge" && (
+ <div>
+ {loadingLoaders ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="animate-spin" />
+ Loading Forge versions...
+ </div>
+ ) : forgeVersions.length > 0 ? (
+ <div className="space-y-2">
+ <select
+ value={selectedForgeLoader}
+ onChange={(e) => setSelectedForgeLoader(e.target.value)}
+ className="w-full px-3 py-2 rounded border bg-transparent"
+ >
+ {forgeVersions.map((f) => (
+ // binding ForgeVersion uses `version` as the identifier
+ <option key={f.version} value={f.version}>
+ {f.version}
+ </option>
+ ))}
+ </select>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ No Forge versions available for this version
+ </p>
+ )}
+ </div>
+ )}
+ </div>
+ )}
+
+ {errorMessage && (
+ <div className="text-sm text-red-400 mt-3">{errorMessage}</div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <div className="w-full flex justify-between items-center">
+ <div>
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={() => {
+ // cancel
+ onOpenChange(false);
+ }}
+ disabled={creating}
+ >
+ Cancel
+ </Button>
+ </div>
+
+ <div className="flex gap-2">
+ {step > 1 && (
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleBack}
+ disabled={creating}
+ >
+ Back
+ </Button>
+ )}
+
+ {step < 3 ? (
+ <Button type="button" onClick={handleNext} disabled={creating}>
+ Next
+ </Button>
+ ) : (
+ <Button
+ type="button"
+ onClick={handleCreate}
+ disabled={creating}
+ >
+ {creating ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Creating...
+ </>
+ ) : (
+ "Create"
+ )}
+ </Button>
+ )}
+ </div>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export default InstanceCreationModal;
diff --git a/packages/ui/src/components/instance-editor-modal.tsx b/packages/ui/src/components/instance-editor-modal.tsx
new file mode 100644
index 0000000..f880c20
--- /dev/null
+++ b/packages/ui/src/components/instance-editor-modal.tsx
@@ -0,0 +1,548 @@
+import { invoke } from "@tauri-apps/api/core";
+import { Folder, Loader2, Save, Trash2, X } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+
+import { toNumber } from "@/lib/tsrs-utils";
+import { useInstancesStore } from "@/models/instances";
+import { useSettingsStore } from "@/models/settings";
+import type { FileInfo } from "../types/bindings/core";
+import type { Instance } from "../types/bindings/instance";
+
+type Props = {
+ open: boolean;
+ instance: Instance | null;
+ onOpenChange: (open: boolean) => void;
+};
+
+export function InstanceEditorModal({ open, instance, onOpenChange }: Props) {
+ const instancesStore = useInstancesStore();
+ const { config } = useSettingsStore();
+
+ const [activeTab, setActiveTab] = useState<
+ "info" | "version" | "files" | "settings"
+ >("info");
+ const [saving, setSaving] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+
+ // Info tab fields
+ const [editName, setEditName] = useState("");
+ const [editNotes, setEditNotes] = useState("");
+
+ // Files tab state
+ const [selectedFileFolder, setSelectedFileFolder] = useState<
+ "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots"
+ >("mods");
+ const [fileList, setFileList] = useState<FileInfo[]>([]);
+ const [loadingFiles, setLoadingFiles] = useState(false);
+ const [deletingPath, setDeletingPath] = useState<string | null>(null);
+
+ // Version tab state (placeholder - the Svelte implementation used a ModLoaderSelector component)
+ // React versions-view/instance-creation handle mod loader installs; here we show basic current info.
+
+ // Settings tab fields
+ const [editMemoryMin, setEditMemoryMin] = useState<number>(0);
+ const [editMemoryMax, setEditMemoryMax] = useState<number>(0);
+ const [editJavaArgs, setEditJavaArgs] = useState<string>("");
+
+ // initialize when open & instance changes
+ useEffect(() => {
+ if (open && instance) {
+ setActiveTab("info");
+ setSaving(false);
+ setErrorMessage("");
+ setEditName(instance.name || "");
+ setEditNotes(instance.notes ?? "");
+ setEditMemoryMin(
+ (instance.memoryOverride && toNumber(instance.memoryOverride.min)) ??
+ config?.minMemory ??
+ 512,
+ );
+ setEditMemoryMax(
+ (instance.memoryOverride && toNumber(instance.memoryOverride.max)) ??
+ config?.maxMemory ??
+ 2048,
+ );
+ setEditJavaArgs(instance.jvmArgsOverride ?? "");
+ setFileList([]);
+ setSelectedFileFolder("mods");
+ }
+ }, [open, instance, config?.minMemory, config?.maxMemory]);
+
+ // load files when switching to files tab
+ const loadFileList = useCallback(
+ async (
+ folder:
+ | "mods"
+ | "resourcepacks"
+ | "shaderpacks"
+ | "saves"
+ | "screenshots",
+ ) => {
+ if (!instance) return;
+ setLoadingFiles(true);
+ try {
+ const files = await invoke<FileInfo[]>("list_instance_directory", {
+ instanceId: instance.id,
+ folder,
+ });
+ setFileList(files || []);
+ } catch (err) {
+ console.error("Failed to load files:", err);
+ toast.error("Failed to load files: " + String(err));
+ setFileList([]);
+ } finally {
+ setLoadingFiles(false);
+ }
+ },
+ [instance],
+ );
+
+ useEffect(() => {
+ if (open && instance && activeTab === "files") {
+ // explicitly pass the selected folder so loadFileList doesn't rely on stale closures
+ loadFileList(selectedFileFolder);
+ }
+ }, [activeTab, open, instance, selectedFileFolder, loadFileList]);
+
+ async function changeFolder(
+ folder: "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots",
+ ) {
+ setSelectedFileFolder(folder);
+ // reload the list for the newly selected folder
+ if (open && instance) await loadFileList(folder);
+ }
+
+ async function deleteFile(filePath: string) {
+ if (
+ !confirm(
+ `Are you sure you want to delete "${filePath.split("/").pop()}"?`,
+ )
+ ) {
+ return;
+ }
+ setDeletingPath(filePath);
+ try {
+ await invoke("delete_instance_file", { path: filePath });
+ // refresh the currently selected folder
+ await loadFileList(selectedFileFolder);
+ toast.success("Deleted");
+ } catch (err) {
+ console.error("Failed to delete file:", err);
+ toast.error("Failed to delete file: " + String(err));
+ } finally {
+ setDeletingPath(null);
+ }
+ }
+
+ async function openInExplorer(filePath: string) {
+ try {
+ await invoke("open_file_explorer", { path: filePath });
+ } catch (err) {
+ console.error("Failed to open in explorer:", err);
+ toast.error("Failed to open file explorer: " + String(err));
+ }
+ }
+
+ async function saveChanges() {
+ if (!instance) return;
+ if (!editName.trim()) {
+ setErrorMessage("Instance name cannot be empty");
+ return;
+ }
+ setSaving(true);
+ setErrorMessage("");
+ try {
+ // Build updated instance shape compatible with backend
+ const updatedInstance: Instance = {
+ ...instance,
+ name: editName.trim(),
+ // some bindings may use camelCase; set optional string fields to null when empty
+ notes: editNotes.trim() ? editNotes.trim() : null,
+ memoryOverride: {
+ min: editMemoryMin,
+ max: editMemoryMax,
+ },
+ jvmArgsOverride: editJavaArgs.trim() ? editJavaArgs.trim() : null,
+ };
+
+ await instancesStore.update(updatedInstance as Instance);
+ toast.success("Instance saved");
+ onOpenChange(false);
+ } catch (err) {
+ console.error("Failed to save instance:", err);
+ setErrorMessage(String(err));
+ toast.error("Failed to save instance: " + String(err));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function formatFileSize(bytesBig: FileInfo["size"]): string {
+ const bytes = Number(bytesBig ?? 0);
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`;
+ }
+
+ function formatDate(
+ tsBig?:
+ | FileInfo["modified"]
+ | Instance["createdAt"]
+ | Instance["lastPlayed"],
+ ) {
+ if (tsBig === undefined || tsBig === null) return "";
+ const n = toNumber(tsBig);
+ // tsrs bindings often use seconds for createdAt/lastPlayed; if value looks like seconds use *1000
+ const maybeMs = n > 1e12 ? n : n * 1000;
+ return new Date(maybeMs).toLocaleDateString();
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="w-full max-w-4xl max-h-[90vh] overflow-hidden">
+ <DialogHeader>
+ <div className="flex items-center justify-between gap-4">
+ <div>
+ <DialogTitle>Edit Instance</DialogTitle>
+ <DialogDescription>{instance?.name ?? ""}</DialogDescription>
+ </div>
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={() => onOpenChange(false)}
+ disabled={saving}
+ className="p-2 rounded hover:bg-zinc-800 text-zinc-400"
+ aria-label="Close"
+ >
+ <X />
+ </button>
+ </div>
+ </div>
+ </DialogHeader>
+
+ {/* Tab Navigation */}
+ <div className="flex gap-1 px-6 pt-2 border-b border-zinc-700">
+ {[
+ { id: "info", label: "Info" },
+ { id: "version", label: "Version" },
+ { id: "files", label: "Files" },
+ { id: "settings", label: "Settings" },
+ ].map((tab) => (
+ <button
+ type="button"
+ key={tab.id}
+ onClick={() =>
+ setActiveTab(
+ tab.id as "info" | "version" | "files" | "settings",
+ )
+ }
+ className={`px-4 py-2 text-sm font-medium transition-colors rounded-t-lg ${
+ activeTab === tab.id
+ ? "bg-indigo-600 text-white"
+ : "bg-zinc-800 text-zinc-400 hover:text-white"
+ }`}
+ >
+ {tab.label}
+ </button>
+ ))}
+ </div>
+
+ {/* Content */}
+ <div className="p-6 overflow-y-auto max-h-[60vh]">
+ {activeTab === "info" && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="instance-name-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Instance Name
+ </label>
+ <Input
+ id="instance-name-edit"
+ value={editName}
+ onChange={(e) => setEditName(e.target.value)}
+ disabled={saving}
+ />
+ </div>
+
+ <div>
+ <label
+ htmlFor="instance-notes-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Notes
+ </label>
+ <Textarea
+ id="instance-notes-edit"
+ value={editNotes}
+ onChange={(e) => setEditNotes(e.target.value)}
+ rows={4}
+ disabled={saving}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Created</p>
+ <p className="text-white font-medium">
+ {instance?.createdAt ? formatDate(instance.createdAt) : "-"}
+ </p>
+ </div>
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Last Played</p>
+ <p className="text-white font-medium">
+ {instance?.lastPlayed
+ ? formatDate(instance.lastPlayed)
+ : "Never"}
+ </p>
+ </div>
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Game Directory</p>
+ <p
+ className="text-white font-medium text-xs truncate"
+ title={instance?.gameDir ?? ""}
+ >
+ {instance?.gameDir
+ ? String(instance.gameDir).split("/").pop()
+ : ""}
+ </p>
+ </div>
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Current Version</p>
+ <p className="text-white font-medium">
+ {instance?.versionId ?? "None"}
+ </p>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {activeTab === "version" && (
+ <div className="space-y-4">
+ {instance?.versionId ? (
+ <div className="p-4 bg-indigo-500/10 border border-indigo-500/30 rounded-lg">
+ <p className="text-sm text-indigo-400">
+ Currently playing:{" "}
+ <span className="font-medium">{instance.versionId}</span>
+ {instance.modLoader && (
+ <>
+ {" "}
+ with{" "}
+ <span className="capitalize">{instance.modLoader}</span>
+ {instance.modLoaderVersion
+ ? ` ${instance.modLoaderVersion}`
+ : ""}
+ </>
+ )}
+ </p>
+ </div>
+ ) : (
+ <div className="text-sm text-zinc-400">
+ No version selected for this instance
+ </div>
+ )}
+
+ <div>
+ <p className="text-sm font-medium mb-2">
+ Change Version / Mod Loader
+ </p>
+ <p className="text-xs text-zinc-400">
+ Use the Versions page to install new game versions or mod
+ loaders, then set them here.
+ </p>
+ </div>
+ </div>
+ )}
+
+ {activeTab === "files" && (
+ <div className="space-y-4">
+ <div className="flex gap-2 flex-wrap">
+ {(
+ [
+ "mods",
+ "resourcepacks",
+ "shaderpacks",
+ "saves",
+ "screenshots",
+ ] as const
+ ).map((folder) => (
+ <button
+ type="button"
+ key={folder}
+ onClick={() => changeFolder(folder)}
+ className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
+ selectedFileFolder === folder
+ ? "bg-indigo-600 text-white"
+ : "bg-zinc-800 text-zinc-400 hover:text-white"
+ }`}
+ >
+ {folder}
+ </button>
+ ))}
+ </div>
+
+ {loadingFiles ? (
+ <div className="flex items-center gap-2 text-zinc-400 py-8 justify-center">
+ <Loader2 className="animate-spin" />
+ Loading files...
+ </div>
+ ) : fileList.length === 0 ? (
+ <div className="text-center py-8 text-zinc-500">
+ No files in this folder
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {fileList.map((file) => (
+ <div
+ key={file.path}
+ className="flex items-center justify-between p-3 bg-zinc-800 rounded-lg hover:bg-zinc-700 transition-colors"
+ >
+ <div className="flex-1 min-w-0">
+ <p className="font-medium text-white truncate">
+ {file.name}
+ </p>
+ <p className="text-xs text-zinc-400">
+ {file.isDirectory
+ ? "Folder"
+ : formatFileSize(file.size)}{" "}
+ • {formatDate(file.modified)}
+ </p>
+ </div>
+ <div className="flex gap-2 ml-4">
+ <button
+ type="button"
+ onClick={() => openInExplorer(file.path)}
+ title="Open in explorer"
+ className="p-2 rounded-lg hover:bg-zinc-600 text-zinc-400 hover:text-white transition-colors"
+ >
+ <Folder />
+ </button>
+ <button
+ type="button"
+ onClick={() => deleteFile(file.path)}
+ disabled={deletingPath === file.path}
+ title="Delete"
+ className="p-2 rounded-lg hover:bg-red-600/20 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
+ >
+ {deletingPath === file.path ? (
+ <Loader2 className="animate-spin" />
+ ) : (
+ <Trash2 />
+ )}
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+
+ {activeTab === "settings" && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="min-memory-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Minimum Memory (MB)
+ </label>
+ <Input
+ id="min-memory-edit"
+ type="number"
+ value={String(editMemoryMin)}
+ onChange={(e) => setEditMemoryMin(Number(e.target.value))}
+ disabled={saving}
+ />
+ <p className="text-xs text-zinc-400 mt-1">
+ Default: {config?.minMemory} MB
+ </p>
+ </div>
+
+ <div>
+ <label
+ htmlFor="max-memory-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Maximum Memory (MB)
+ </label>
+ <Input
+ id="max-memory-edit"
+ type="number"
+ value={String(editMemoryMax)}
+ onChange={(e) => setEditMemoryMax(Number(e.target.value))}
+ disabled={saving}
+ />
+ <p className="text-xs text-zinc-400 mt-1">
+ Default: {config?.maxMemory} MB
+ </p>
+ </div>
+
+ <div>
+ <label
+ htmlFor="jvm-args-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ JVM Arguments (Advanced)
+ </label>
+ <Textarea
+ id="jvm-args-edit"
+ value={editJavaArgs}
+ onChange={(e) => setEditJavaArgs(e.target.value)}
+ rows={4}
+ disabled={saving}
+ />
+ </div>
+ </div>
+ )}
+ </div>
+
+ {errorMessage && (
+ <div className="px-6 text-sm text-red-400">{errorMessage}</div>
+ )}
+
+ <DialogFooter>
+ <div className="flex items-center justify-between w-full">
+ <div />
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button onClick={saveChanges} disabled={saving}>
+ {saving ? (
+ <Loader2 className="animate-spin mr-2" />
+ ) : (
+ <Save className="mr-2" />
+ )}
+ Save
+ </Button>
+ </div>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export default InstanceEditorModal;
diff --git a/packages/ui/src/components/login-modal.tsx b/packages/ui/src/components/login-modal.tsx
new file mode 100644
index 0000000..49596da
--- /dev/null
+++ b/packages/ui/src/components/login-modal.tsx
@@ -0,0 +1,188 @@
+import { Mail, User } from "lucide-react";
+import { useCallback, useState } from "react";
+import { toast } from "sonner";
+import { useAuthStore } from "@/models/auth";
+import { Button } from "./ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "./ui/dialog";
+import {
+ Field,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+} from "./ui/field";
+import { Input } from "./ui/input";
+
+export interface LoginModalProps
+ extends Omit<React.ComponentPropsWithoutRef<typeof Dialog>, "onOpenChange"> {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function LoginModal({ onOpenChange, ...props }: LoginModalProps) {
+ const authStore = useAuthStore();
+
+ const [offlineUsername, setOfflineUsername] = useState<string>("");
+ const [errorMessage, setErrorMessage] = useState<string>("");
+ const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
+
+ const handleMicrosoftLogin = useCallback(async () => {
+ setIsLoggingIn(true);
+ authStore.setLoginMode("microsoft");
+ try {
+ await authStore.loginOnline(() => onOpenChange?.(false));
+ } catch (error) {
+ const err = error as Error;
+ console.error("Failed to login with Microsoft:", err);
+ setErrorMessage(err.message);
+ } finally {
+ setIsLoggingIn(false);
+ }
+ }, [authStore.loginOnline, authStore.setLoginMode, onOpenChange]);
+
+ const handleOfflineLogin = useCallback(async () => {
+ setIsLoggingIn(true);
+ try {
+ await authStore.loginOffline(offlineUsername);
+ toast.success("Logged in offline successfully");
+ onOpenChange?.(false);
+ } catch (error) {
+ const err = error as Error;
+ console.error("Failed to login offline:", err);
+ setErrorMessage(err.message);
+ } finally {
+ setIsLoggingIn(false);
+ }
+ }, [authStore, offlineUsername, onOpenChange]);
+
+ return (
+ <Dialog onOpenChange={onOpenChange} {...props}>
+ <DialogContent className="md:max-w-md">
+ <DialogHeader>
+ <DialogTitle>Login</DialogTitle>
+ <DialogDescription>
+ Login to your Minecraft account or play offline
+ </DialogDescription>
+ </DialogHeader>
+ <div className="p-4 w-full overflow-hidden">
+ {!authStore.loginMode && (
+ <div className="flex flex-col space-y-4">
+ <Button size="lg" onClick={handleMicrosoftLogin}>
+ <Mail />
+ Login with Microsoft
+ </Button>
+ <Button
+ variant="secondary"
+ onClick={() => authStore.setLoginMode("offline")}
+ size="lg"
+ >
+ <User />
+ Login Offline
+ </Button>
+ </div>
+ )}
+ {authStore.loginMode === "microsoft" && (
+ <div className="flex flex-col space-y-4">
+ <button
+ type="button"
+ className="text-4xl font-bold text-center bg-accent p-4 cursor-pointer"
+ onClick={() => {
+ if (authStore.deviceCode?.userCode) {
+ navigator.clipboard?.writeText(
+ authStore.deviceCode?.userCode,
+ );
+ toast.success("Copied to clipboard");
+ }
+ }}
+ >
+ {authStore.deviceCode?.userCode}
+ </button>
+ <span className="text-muted-foreground w-full overflow-hidden text-ellipsis">
+ To sign in, use a web browser to open the page{" "}
+ <a href={authStore.deviceCode?.verificationUri}>
+ {authStore.deviceCode?.verificationUri}
+ </a>{" "}
+ and enter the code{" "}
+ <code
+ className="font-semibold cursor-pointer"
+ onClick={() => {
+ if (authStore.deviceCode?.userCode) {
+ navigator.clipboard?.writeText(
+ authStore.deviceCode?.userCode,
+ );
+ }
+ }}
+ onKeyDown={() => {
+ if (authStore.deviceCode?.userCode) {
+ navigator.clipboard?.writeText(
+ authStore.deviceCode?.userCode,
+ );
+ }
+ }}
+ >
+ {authStore.deviceCode?.userCode}
+ </code>{" "}
+ to authenticate, this code will be expired in{" "}
+ {authStore.deviceCode?.expiresIn} seconds.
+ </span>
+ <FieldError>{errorMessage}</FieldError>
+ </div>
+ )}
+ {authStore.loginMode === "offline" && (
+ <FieldGroup>
+ <Field>
+ <FieldLabel>Username</FieldLabel>
+ <FieldDescription>
+ Enter a username to play offline
+ </FieldDescription>
+ <Input
+ value={offlineUsername}
+ onChange={(e) => {
+ setOfflineUsername(e.target.value);
+ setErrorMessage("");
+ }}
+ aria-invalid={!!errorMessage}
+ />
+ <FieldError>{errorMessage}</FieldError>
+ </Field>
+ </FieldGroup>
+ )}
+ </div>
+ <DialogFooter>
+ <div className="flex flex-col justify-center items-center">
+ <span className="text-xs text-muted-foreground ">
+ {authStore.statusMessage}
+ </span>
+ </div>
+ <Button
+ variant="outline"
+ onClick={() => {
+ if (authStore.loginMode) {
+ if (authStore.loginMode === "microsoft") {
+ authStore.cancelLoginOnline();
+ }
+ authStore.setLoginMode(null);
+ } else {
+ onOpenChange?.(false);
+ }
+ }}
+ >
+ Cancel
+ </Button>
+ {authStore.loginMode === "offline" && (
+ <Button onClick={handleOfflineLogin} disabled={isLoggingIn}>
+ Login
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/packages/ui/src/components/particle-background.tsx b/packages/ui/src/components/particle-background.tsx
new file mode 100644
index 0000000..2e0b15a
--- /dev/null
+++ b/packages/ui/src/components/particle-background.tsx
@@ -0,0 +1,63 @@
+import { useEffect, useRef } from "react";
+import { SaturnEffect } from "../lib/effects/SaturnEffect";
+
+export function ParticleBackground() {
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
+ const effectRef = useRef<SaturnEffect | null>(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ // Instantiate SaturnEffect and attach to canvas
+ let effect: SaturnEffect | null = null;
+ try {
+ effect = new SaturnEffect(canvas);
+ effectRef.current = effect;
+ } catch (err) {
+ // If effect fails, silently degrade (keep background blank)
+ // eslint-disable-next-line no-console
+ console.warn("SaturnEffect initialization failed:", err);
+ }
+
+ const resizeHandler = () => {
+ if (effectRef.current) {
+ try {
+ effectRef.current.resize(window.innerWidth, window.innerHeight);
+ } catch {
+ // ignore
+ }
+ }
+ };
+
+ window.addEventListener("resize", resizeHandler);
+
+ // Expose getter for HomeView interactions (getSaturnEffect)
+ // HomeView will call window.getSaturnEffect()?.handleMouseDown/Move/Up
+ (
+ window as unknown as { getSaturnEffect?: () => SaturnEffect | null }
+ ).getSaturnEffect = () => effectRef.current;
+
+ return () => {
+ window.removeEventListener("resize", resizeHandler);
+ if (effectRef.current) {
+ try {
+ effectRef.current.destroy();
+ } catch {
+ // ignore
+ }
+ }
+ effectRef.current = null;
+ (
+ window as unknown as { getSaturnEffect?: () => SaturnEffect | null }
+ ).getSaturnEffect = undefined;
+ };
+ }, []);
+
+ return (
+ <canvas
+ ref={canvasRef}
+ className="absolute inset-0 z-0 pointer-events-none"
+ />
+ );
+}
diff --git a/packages/ui/src/components/sidebar.tsx b/packages/ui/src/components/sidebar.tsx
new file mode 100644
index 0000000..0147b0a
--- /dev/null
+++ b/packages/ui/src/components/sidebar.tsx
@@ -0,0 +1,185 @@
+import { Folder, Home, LogOutIcon, Settings } from "lucide-react";
+import { useLocation, useNavigate } from "react-router";
+import { cn } from "@/lib/utils";
+import { useAuthStore } from "@/models/auth";
+import { Button } from "./ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
+import { UserAvatar } from "./user-avatar";
+
+interface NavItemProps {
+ Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
+ label: string;
+ to: string;
+}
+
+function NavItem({ Icon, label, to }: NavItemProps) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const isActive = location.pathname === to;
+
+ const handleClick = () => {
+ navigate(to);
+ };
+
+ return (
+ <Button
+ variant="ghost"
+ className={cn(
+ "w-fit lg:w-full justify-center lg:justify-start",
+ isActive && "relative bg-accent",
+ )}
+ size="lg"
+ onClick={handleClick}
+ >
+ <Icon className="size-5" strokeWidth={isActive ? 2.5 : 2} />
+ <span className="hidden lg:block text-sm relative z-10">{label}</span>
+ {isActive && (
+ <div className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-black dark:bg-white rounded-r-full hidden lg:block"></div>
+ )}
+ </Button>
+ );
+}
+
+export function Sidebar() {
+ const authStore = useAuthStore();
+
+ return (
+ <aside
+ className={cn(
+ "flex flex-col items-center lg:items-start",
+ "bg-sidebar transition-all duration-300",
+ "w-20 lg:w-64 shrink-0 py-6 h-full",
+ )}
+ >
+ {/* Logo Area */}
+ <div className="h-16 w-full flex items-center justify-center lg:justify-start lg:px-6 mb-6">
+ {/* Icon Logo (Small) */}
+ <div className="lg:hidden text-black dark:text-white">
+ <svg
+ width="32"
+ height="32"
+ viewBox="0 0 100 100"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Logo</title>
+ <path
+ d="M25 25 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M25 75 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M50 50 L75 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
+ <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
+ <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
+ </svg>
+ </div>
+ {/* Full Logo (Large) */}
+ <div className="hidden lg:flex items-center gap-3 font-bold text-xl tracking-tighter dark:text-white text-black">
+ <svg
+ width="42"
+ height="42"
+ viewBox="0 0 100 100"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ className="shrink-0"
+ >
+ <title>Logo</title>
+ <path
+ d="M25 25 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M25 75 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M50 50 L75 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+
+ <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
+
+ <circle
+ cx="50"
+ cy="25"
+ r="7"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeDasharray="4 2"
+ fill="none"
+ className="opacity-30"
+ />
+ <circle
+ cx="50"
+ cy="75"
+ r="7"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeDasharray="4 2"
+ fill="none"
+ className="opacity-30"
+ />
+ <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
+ <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
+ </svg>
+
+ <span>DROPOUT</span>
+ </div>
+ </div>
+
+ <nav className="w-full flex flex-col space-y-1 px-3 items-center">
+ <NavItem Icon={Home} label="Overview" to="/" />
+ <NavItem Icon={Folder} label="Instances" to="/instances" />
+ <NavItem Icon={Settings} label="Settings" to="/settings" />
+ </nav>
+
+ <div className="flex-1 flex flex-col justify-end">
+ <DropdownMenu>
+ <DropdownMenuTrigger render={<UserAvatar />}>
+ Open
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" side="right" sideOffset={20}>
+ <DropdownMenuGroup>
+ <DropdownMenuItem
+ variant="destructive"
+ onClick={authStore.logout}
+ >
+ <LogOutIcon />
+ Logout
+ </DropdownMenuItem>
+ </DropdownMenuGroup>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </aside>
+ );
+}
diff --git a/packages/ui/src/components/ui/avatar.tsx b/packages/ui/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..9fd72a2
--- /dev/null
+++ b/packages/ui/src/components/ui/avatar.tsx
@@ -0,0 +1,107 @@
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: AvatarPrimitive.Root.Props & {
+ size?: "default" | "sm" | "lg";
+}) {
+ return (
+ <AvatarPrimitive.Root
+ data-slot="avatar"
+ data-size={size}
+ className={cn(
+ "size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+ <AvatarPrimitive.Image
+ data-slot="avatar-image"
+ className={cn(
+ "rounded-full aspect-square size-full object-cover",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: AvatarPrimitive.Fallback.Props) {
+ return (
+ <AvatarPrimitive.Fallback
+ data-slot="avatar-fallback"
+ className={cn(
+ "bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="avatar-badge"
+ className={cn(
+ "bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
+ "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="avatar-group"
+ className={cn(
+ "*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="avatar-group-count"
+ className={cn(
+ "bg-muted text-muted-foreground size-8 rounded-full text-xs group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+};
diff --git a/packages/ui/src/components/ui/badge.tsx b/packages/ui/src/components/ui/badge.tsx
new file mode 100644
index 0000000..425ab9e
--- /dev/null
+++ b/packages/ui/src/components/ui/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "h-5 gap-1 rounded-none border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ });
+}
+
+export { Badge, badgeVariants };
diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx
new file mode 100644
index 0000000..7dee494
--- /dev/null
+++ b/packages/ui/src/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import { Button as ButtonPrimitive } from "@base-ui/react/button";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
+ destructive:
+ "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
+ icon: "size-8",
+ "icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-7 rounded-none",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
+ return (
+ <ButtonPrimitive
+ data-slot="button"
+ className={cn(buttonVariants({ variant, size, className }))}
+ {...props}
+ />
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/packages/ui/src/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx
new file mode 100644
index 0000000..b7084a0
--- /dev/null
+++ b/packages/ui/src/components/ui/card.tsx
@@ -0,0 +1,103 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+ <div
+ data-slot="card"
+ data-size={size}
+ className={cn(
+ "ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none group/card flex flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-header"
+ className={cn(
+ "gap-1 rounded-none px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-title"
+ className={cn(
+ "text-sm font-medium group-data-[size=sm]/card:text-sm",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-description"
+ className={cn("text-muted-foreground text-xs/relaxed", className)}
+ {...props}
+ />
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-action"
+ className={cn(
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-content"
+ className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
+ {...props}
+ />
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-footer"
+ className={cn(
+ "rounded-none border-t p-4 group-data-[size=sm]/card:p-3 flex items-center",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+};
diff --git a/packages/ui/src/components/ui/checkbox.tsx b/packages/ui/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..9f22cea
--- /dev/null
+++ b/packages/ui/src/components/ui/checkbox.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
+import { CheckIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
+ return (
+ <CheckboxPrimitive.Root
+ data-slot="checkbox"
+ className={cn(
+ "border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-none border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-1 aria-invalid:ring-1 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <CheckboxPrimitive.Indicator
+ data-slot="checkbox-indicator"
+ className="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
+ >
+ <CheckIcon />
+ </CheckboxPrimitive.Indicator>
+ </CheckboxPrimitive.Root>
+ );
+}
+
+export { Checkbox };
diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..033b47c
--- /dev/null
+++ b/packages/ui/src/components/ui/dialog.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
+import { XIcon } from "lucide-react";
+import type * as React from "react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />;
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+ <DialogPrimitive.Backdrop
+ data-slot="dialog-overlay"
+ className={cn(
+ "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean;
+}) {
+ return (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Popup
+ data-slot="dialog-content"
+ className={cn(
+ "bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-none p-4 text-xs/relaxed ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ {showCloseButton && (
+ <DialogPrimitive.Close
+ data-slot="dialog-close"
+ render={
+ <Button
+ variant="ghost"
+ className="absolute top-2 right-2"
+ size="icon-sm"
+ />
+ }
+ >
+ <XIcon />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ )}
+ </DialogPrimitive.Popup>
+ </DialogPortal>
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="dialog-header"
+ className={cn("gap-1 text-left flex flex-col", className)}
+ {...props}
+ />
+ );
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean;
+}) {
+ return (
+ <div
+ data-slot="dialog-footer"
+ className={cn(
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ {showCloseButton && (
+ <DialogPrimitive.Close render={<Button variant="outline" />}>
+ Close
+ </DialogPrimitive.Close>
+ )}
+ </div>
+ );
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+ <DialogPrimitive.Title
+ data-slot="dialog-title"
+ className={cn("text-sm font-medium", className)}
+ {...props}
+ />
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+ <DialogPrimitive.Description
+ data-slot="dialog-description"
+ className={cn(
+ "text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed *:[a]:underline *:[a]:underline-offset-3",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..ee97374
--- /dev/null
+++ b/packages/ui/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,269 @@
+import { Menu as MenuPrimitive } from "@base-ui/react/menu";
+import { CheckIcon, ChevronRightIcon } from "lucide-react";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+ <MenuPrimitive.Portal>
+ <MenuPrimitive.Positioner
+ className="isolate z-50 outline-none"
+ align={align}
+ alignOffset={alignOffset}
+ side={side}
+ sideOffset={sideOffset}
+ >
+ <MenuPrimitive.Popup
+ data-slot="dropdown-menu-content"
+ className={cn(
+ "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-none shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden",
+ className,
+ )}
+ {...props}
+ />
+ </MenuPrimitive.Positioner>
+ </MenuPrimitive.Portal>
+ );
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.GroupLabel
+ data-slot="dropdown-menu-label"
+ data-inset={inset}
+ className={cn(
+ "text-muted-foreground px-2 py-2 text-xs data-inset:pl-7",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+ <MenuPrimitive.Item
+ data-slot="dropdown-menu-item"
+ data-inset={inset}
+ data-variant={variant}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none px-2 py-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.SubmenuTrigger
+ data-slot="dropdown-menu-sub-trigger"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none px-2 py-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 data-popup-open:bg-accent data-popup-open:text-accent-foreground flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronRightIcon className="ml-auto" />
+ </MenuPrimitive.SubmenuTrigger>
+ );
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuContent>) {
+ return (
+ <DropdownMenuContent
+ data-slot="dropdown-menu-sub-content"
+ className={cn(
+ "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-24 rounded-none shadow-lg ring-1 duration-100 w-auto",
+ className,
+ )}
+ align={align}
+ alignOffset={alignOffset}
+ side={side}
+ sideOffset={sideOffset}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.CheckboxItem
+ data-slot="dropdown-menu-checkbox-item"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ checked={checked}
+ {...props}
+ >
+ <span
+ className="absolute right-2 flex items-center justify-center pointer-events-none"
+ data-slot="dropdown-menu-checkbox-item-indicator"
+ >
+ <MenuPrimitive.CheckboxItemIndicator>
+ <CheckIcon />
+ </MenuPrimitive.CheckboxItemIndicator>
+ </span>
+ {children}
+ </MenuPrimitive.CheckboxItem>
+ );
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+ <MenuPrimitive.RadioGroup
+ data-slot="dropdown-menu-radio-group"
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.RadioItem
+ data-slot="dropdown-menu-radio-item"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ <span
+ className="absolute right-2 flex items-center justify-center pointer-events-none"
+ data-slot="dropdown-menu-radio-item-indicator"
+ >
+ <MenuPrimitive.RadioItemIndicator>
+ <CheckIcon />
+ </MenuPrimitive.RadioItemIndicator>
+ </span>
+ {children}
+ </MenuPrimitive.RadioItem>
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+ <MenuPrimitive.Separator
+ data-slot="dropdown-menu-separator"
+ className={cn("bg-border -mx-1 h-px", className)}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="dropdown-menu-shortcut"
+ className={cn(
+ "text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/packages/ui/src/components/ui/field.tsx b/packages/ui/src/components/ui/field.tsx
new file mode 100644
index 0000000..ab9fb71
--- /dev/null
+++ b/packages/ui/src/components/ui/field.tsx
@@ -0,0 +1,238 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { useMemo } from "react";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+ <fieldset
+ data-slot="field-set"
+ className={cn(
+ "gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldLegend({
+ className,
+ variant = "legend",
+ ...props
+}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
+ return (
+ <legend
+ data-slot="field-legend"
+ data-variant={variant}
+ className={cn(
+ "mb-2.5 font-medium data-[variant=label]:text-xs data-[variant=legend]:text-sm",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-group"
+ className={cn(
+ "gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const fieldVariants = cva(
+ "data-[invalid=true]:text-destructive gap-2 group/field flex w-full",
+ {
+ variants: {
+ orientation: {
+ vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
+ horizontal:
+ "flex-row items-center *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ responsive:
+ "flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:*:data-[slot=field-label]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ },
+ },
+ defaultVariants: {
+ orientation: "vertical",
+ },
+ },
+);
+
+function Field({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
+ return (
+ <div
+ data-slot="field"
+ data-orientation={orientation}
+ className={cn(fieldVariants({ orientation }), className)}
+ {...props}
+ />
+ );
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-content"
+ className={cn(
+ "gap-0.5 group/field-content flex flex-1 flex-col leading-snug",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps<typeof Label>) {
+ return (
+ <Label
+ data-slot="field-label"
+ className={cn(
+ "has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 group/field-label peer/field-label flex w-fit leading-snug",
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-label"
+ className={cn(
+ "gap-2 text-xs/relaxed group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ <p
+ data-slot="field-description"
+ className={cn(
+ "text-muted-foreground text-left text-xs/relaxed [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance",
+ "last:mt-0 nth-last-2:-mt-1",
+ "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode;
+}) {
+ return (
+ <div
+ data-slot="field-separator"
+ data-content={!!children}
+ className={cn(
+ "-my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2 relative",
+ className,
+ )}
+ {...props}
+ >
+ <Separator className="absolute inset-0 top-1/2" />
+ {children && (
+ <span
+ className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
+ data-slot="field-separator-content"
+ >
+ {children}
+ </span>
+ )}
+ </div>
+ );
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined>;
+}) {
+ const content = useMemo(() => {
+ if (children) {
+ return children;
+ }
+
+ if (!errors?.length) {
+ return null;
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ];
+
+ if (uniqueErrors?.length === 1) {
+ return uniqueErrors[0]?.message;
+ }
+
+ return (
+ <ul className="ml-4 flex list-disc flex-col gap-1">
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && (
+ <li key={`${error.message.slice(6)}-${index}`}>
+ {error.message}
+ </li>
+ ),
+ )}
+ </ul>
+ );
+ }, [children, errors]);
+
+ if (!content) {
+ return null;
+ }
+
+ return (
+ <div
+ role="alert"
+ data-slot="field-error"
+ className={cn("text-destructive text-xs font-normal", className)}
+ {...props}
+ >
+ {content}
+ </div>
+ );
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+};
diff --git a/packages/ui/src/components/ui/input.tsx b/packages/ui/src/components/ui/input.tsx
new file mode 100644
index 0000000..bb0390a
--- /dev/null
+++ b/packages/ui/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import { Input as InputPrimitive } from "@base-ui/react/input";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+ <InputPrimitive
+ type={type}
+ data-slot="input"
+ className={cn(
+ "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-none border bg-transparent px-2.5 py-1 text-xs transition-colors file:h-6 file:text-xs file:font-medium focus-visible:ring-1 aria-invalid:ring-1 md:text-xs file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Input };
diff --git a/packages/ui/src/components/ui/label.tsx b/packages/ui/src/components/ui/label.tsx
new file mode 100644
index 0000000..9a998c7
--- /dev/null
+++ b/packages/ui/src/components/ui/label.tsx
@@ -0,0 +1,19 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+ // biome-ignore lint/a11y/noLabelWithoutControl: shadcn component
+ <label
+ data-slot="label"
+ className={cn(
+ "gap-2 text-xs leading-none group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Label };
diff --git a/packages/ui/src/components/ui/scroll-area.tsx b/packages/ui/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..4a68eb2
--- /dev/null
+++ b/packages/ui/src/components/ui/scroll-area.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
+import { cn } from "@/lib/utils";
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: ScrollAreaPrimitive.Root.Props) {
+ return (
+ <ScrollAreaPrimitive.Root
+ data-slot="scroll-area"
+ className={cn("relative", className)}
+ {...props}
+ >
+ <ScrollAreaPrimitive.Viewport
+ data-slot="scroll-area-viewport"
+ className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
+ >
+ {children}
+ </ScrollAreaPrimitive.Viewport>
+ <ScrollBar />
+ <ScrollAreaPrimitive.Corner />
+ </ScrollAreaPrimitive.Root>
+ );
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props) {
+ return (
+ <ScrollAreaPrimitive.Scrollbar
+ data-slot="scroll-area-scrollbar"
+ data-orientation={orientation}
+ orientation={orientation}
+ className={cn(
+ "data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
+ className,
+ )}
+ {...props}
+ >
+ <ScrollAreaPrimitive.Thumb
+ data-slot="scroll-area-thumb"
+ className="rounded-none bg-border relative flex-1"
+ />
+ </ScrollAreaPrimitive.Scrollbar>
+ );
+}
+
+export { ScrollArea, ScrollBar };
diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx
new file mode 100644
index 0000000..210adba
--- /dev/null
+++ b/packages/ui/src/components/ui/select.tsx
@@ -0,0 +1,199 @@
+import { Select as SelectPrimitive } from "@base-ui/react/select";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+ <SelectPrimitive.Group
+ data-slot="select-group"
+ className={cn("scroll-my-1", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+ <SelectPrimitive.Value
+ data-slot="select-value"
+ className={cn("flex flex-1 text-left", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default";
+}) {
+ return (
+ <SelectPrimitive.Trigger
+ data-slot="select-trigger"
+ data-size={size}
+ className={cn(
+ "border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-none border bg-transparent py-2 pr-2 pl-2.5 text-xs transition-colors select-none focus-visible:ring-1 aria-invalid:ring-1 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <SelectPrimitive.Icon
+ render={
+ <ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
+ }
+ />
+ </SelectPrimitive.Trigger>
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+ <SelectPrimitive.Portal>
+ <SelectPrimitive.Positioner
+ side={side}
+ sideOffset={sideOffset}
+ align={align}
+ alignOffset={alignOffset}
+ alignItemWithTrigger={alignItemWithTrigger}
+ className="isolate z-50"
+ >
+ <SelectPrimitive.Popup
+ data-slot="select-content"
+ data-align-trigger={alignItemWithTrigger}
+ className={cn(
+ "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-none shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none",
+ className,
+ )}
+ {...props}
+ >
+ <SelectScrollUpButton />
+ <SelectPrimitive.List>{children}</SelectPrimitive.List>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Popup>
+ </SelectPrimitive.Positioner>
+ </SelectPrimitive.Portal>
+ );
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+ <SelectPrimitive.GroupLabel
+ data-slot="select-label"
+ className={cn("text-muted-foreground px-2 py-2 text-xs", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+ <SelectPrimitive.Item
+ data-slot="select-item"
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ <SelectPrimitive.ItemText className="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
+ {children}
+ </SelectPrimitive.ItemText>
+ <SelectPrimitive.ItemIndicator
+ render={
+ <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
+ }
+ >
+ <CheckIcon className="pointer-events-none" />
+ </SelectPrimitive.ItemIndicator>
+ </SelectPrimitive.Item>
+ );
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+ <SelectPrimitive.Separator
+ data-slot="select-separator"
+ className={cn("bg-border -mx-1 h-px pointer-events-none", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
+ return (
+ <SelectPrimitive.ScrollUpArrow
+ data-slot="select-scroll-up-button"
+ className={cn(
+ "bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full",
+ className,
+ )}
+ {...props}
+ >
+ <ChevronUpIcon />
+ </SelectPrimitive.ScrollUpArrow>
+ );
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
+ return (
+ <SelectPrimitive.ScrollDownArrow
+ data-slot="select-scroll-down-button"
+ className={cn(
+ "bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full",
+ className,
+ )}
+ {...props}
+ >
+ <ChevronDownIcon />
+ </SelectPrimitive.ScrollDownArrow>
+ );
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/packages/ui/src/components/ui/separator.tsx b/packages/ui/src/components/ui/separator.tsx
new file mode 100644
index 0000000..e91a862
--- /dev/null
+++ b/packages/ui/src/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
+
+import { cn } from "@/lib/utils";
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+ <SeparatorPrimitive
+ data-slot="separator"
+ orientation={orientation}
+ className={cn(
+ "bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Separator };
diff --git a/packages/ui/src/components/ui/sonner.tsx b/packages/ui/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..d6e293d
--- /dev/null
+++ b/packages/ui/src/components/ui/sonner.tsx
@@ -0,0 +1,43 @@
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+} from "lucide-react";
+import { useTheme } from "next-themes";
+import { Toaster as Sonner, type ToasterProps } from "sonner";
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme();
+
+ return (
+ <Sonner
+ theme={theme as ToasterProps["theme"]}
+ className="toaster group"
+ icons={{
+ success: <CircleCheckIcon className="size-4" />,
+ info: <InfoIcon className="size-4" />,
+ warning: <TriangleAlertIcon className="size-4" />,
+ error: <OctagonXIcon className="size-4" />,
+ loading: <Loader2Icon className="size-4 animate-spin" />,
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ );
+};
+
+export { Toaster };
diff --git a/packages/ui/src/components/ui/spinner.tsx b/packages/ui/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..91f6a63
--- /dev/null
+++ b/packages/ui/src/components/ui/spinner.tsx
@@ -0,0 +1,10 @@
+import { cn } from "@/lib/utils"
+import { Loader2Icon } from "lucide-react"
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return (
+ <Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
+ )
+}
+
+export { Spinner }
diff --git a/packages/ui/src/components/ui/switch.tsx b/packages/ui/src/components/ui/switch.tsx
new file mode 100644
index 0000000..fef14e3
--- /dev/null
+++ b/packages/ui/src/components/ui/switch.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
+
+import { cn } from "@/lib/utils";
+
+function Switch({
+ className,
+ size = "default",
+ ...props
+}: SwitchPrimitive.Root.Props & {
+ size?: "sm" | "default";
+}) {
+ return (
+ <SwitchPrimitive.Root
+ data-slot="switch"
+ data-size={size}
+ className={cn(
+ "data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-1 aria-invalid:ring-1 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <SwitchPrimitive.Thumb
+ data-slot="switch-thumb"
+ className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
+ />
+ </SwitchPrimitive.Root>
+ );
+}
+
+export { Switch };
diff --git a/packages/ui/src/components/ui/tabs.tsx b/packages/ui/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..c66893f
--- /dev/null
+++ b/packages/ui/src/components/ui/tabs.tsx
@@ -0,0 +1,80 @@
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+ <TabsPrimitive.Root
+ data-slot="tabs"
+ data-orientation={orientation}
+ className={cn(
+ "gap-2 group/tabs flex data-horizontal:flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const tabsListVariants = cva(
+ "rounded-none p-0.75 group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
+ return (
+ <TabsPrimitive.List
+ data-slot="tabs-list"
+ data-variant={variant}
+ className={cn(tabsListVariants({ variant }), className)}
+ {...props}
+ />
+ );
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+ <TabsPrimitive.Tab
+ data-slot="tabs-trigger"
+ className={cn(
+ "gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium group-data-vertical/tabs:py-[calc(--spacing(1.25))] [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
+ "data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
+ "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:-bottom-1.25 group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+ <TabsPrimitive.Panel
+ data-slot="tabs-content"
+ className={cn("text-xs/relaxed flex-1 outline-none", className)}
+ {...props}
+ />
+ );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
diff --git a/packages/ui/src/components/ui/textarea.tsx b/packages/ui/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..3c3e5d0
--- /dev/null
+++ b/packages/ui/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+ <textarea
+ data-slot="textarea"
+ className={cn(
+ "border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-none border bg-transparent px-2.5 py-2 text-xs transition-colors focus-visible:ring-1 aria-invalid:ring-1 md:text-xs placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Textarea };
diff --git a/packages/ui/src/components/user-avatar.tsx b/packages/ui/src/components/user-avatar.tsx
new file mode 100644
index 0000000..bbdb84c
--- /dev/null
+++ b/packages/ui/src/components/user-avatar.tsx
@@ -0,0 +1,23 @@
+import { useAuthStore } from "@/models/auth";
+import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "./ui/avatar";
+
+export function UserAvatar({
+ className,
+ ...props
+}: React.ComponentProps<typeof Avatar>) {
+ const authStore = useAuthStore();
+
+ if (!authStore.account) {
+ return null;
+ }
+
+ return (
+ <Avatar {...props}>
+ <AvatarImage
+ src={`https://minotar.net/helm/${authStore.account.username}/100.png`}
+ />
+ <AvatarFallback>{authStore.account.username.slice(0, 2)}</AvatarFallback>
+ <AvatarBadge />
+ </Avatar>
+ );
+}
diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css
new file mode 100644
index 0000000..8803e5e
--- /dev/null
+++ b/packages/ui/src/index.css
@@ -0,0 +1,126 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+ --radius-3xl: calc(var(--radius) + 12px);
+ --radius-4xl: calc(var(--radius) + 16px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: #f3f4f6; /* bg-gray-100 */
+ --foreground: #18181b; /* zinc-900 */
+ --card: #ffffff;
+ --card-foreground: #18181b;
+ --popover: #ffffff;
+ --popover-foreground: #18181b;
+ --primary: #4f46e5; /* indigo-600 */
+ --primary-foreground: #ffffff;
+ --secondary: #f4f4f5; /* zinc-100 */
+ --secondary-foreground: #18181b;
+ --muted: #f4f4f5; /* zinc-100 */
+ --muted-foreground: #71717a; /* zinc-500 */
+ --accent: #f4f4f5; /* zinc-100 */
+ --accent-foreground: #18181b;
+ --destructive: #ef4444; /* red-500 */
+ --destructive-foreground: #ffffff;
+ --border: #e4e4e7; /* zinc-200 */
+ --input: #ffffff;
+ --ring: #6366f1; /* indigo-500 */
+ --chart-1: #059669; /* emerald-600 */
+ --chart-2: #0d9488; /* teal-600 */
+ --chart-3: #4f46e5; /* indigo-600 */
+ --chart-4: #7c3aed; /* violet-600 */
+ --chart-5: #dc2626; /* red-600 */
+ --sidebar: #ffffff;
+ --sidebar-foreground: #18181b;
+ --sidebar-primary: #4f46e5; /* indigo-600 */
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #f4f4f5; /* zinc-100 */
+ --sidebar-accent-foreground: #18181b;
+ --sidebar-border: #e4e4e7; /* zinc-200 */
+ --sidebar-ring: #6366f1; /* indigo-500 */
+}
+
+.dark {
+ --background: #09090b;
+ --foreground: #fafafa; /* zinc-50 */
+ --card: #18181b; /* zinc-900 */
+ --card-foreground: #fafafa;
+ --popover: #18181b;
+ --popover-foreground: #fafafa;
+ --primary: #6366f1; /* indigo-500 */
+ --primary-foreground: #ffffff;
+ --secondary: #27272a; /* zinc-800 */
+ --secondary-foreground: #fafafa;
+ --muted: #27272a; /* zinc-800 */
+ --muted-foreground: #a1a1aa; /* zinc-400 */
+ --accent: #27272a; /* zinc-800 */
+ --accent-foreground: #fafafa;
+ --destructive: #f87171; /* red-400 */
+ --destructive-foreground: #ffffff;
+ --border: #3f3f46; /* zinc-700 */
+ --input: rgba(255, 255, 255, 0.15);
+ --ring: #6366f1; /* indigo-500 */
+ --chart-1: #10b981; /* emerald-500 */
+ --chart-2: #06b6d4; /* cyan-500 */
+ --chart-3: #6366f1; /* indigo-500 */
+ --chart-4: #8b5cf6; /* violet-500 */
+ --chart-5: #f87171; /* red-400 */
+ --sidebar: #09090b;
+ --sidebar-foreground: #fafafa;
+ --sidebar-primary: #6366f1; /* indigo-500 */
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #27272a; /* zinc-800 */
+ --sidebar-accent-foreground: #fafafa;
+ --sidebar-border: #3f3f46; /* zinc-700 */
+ --sidebar-ring: #6366f1; /* indigo-500 */
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/packages/ui/src/lib/Counter.svelte b/packages/ui/src/lib/Counter.svelte
deleted file mode 100644
index 37d75ce..0000000
--- a/packages/ui/src/lib/Counter.svelte
+++ /dev/null
@@ -1,10 +0,0 @@
-<script lang="ts">
- let count: number = $state(0)
- const increment = () => {
- count += 1
- }
-</script>
-
-<button onclick={increment}>
- count is {count}
-</button>
diff --git a/packages/ui/src/lib/DownloadMonitor.svelte b/packages/ui/src/lib/DownloadMonitor.svelte
deleted file mode 100644
index 860952c..0000000
--- a/packages/ui/src/lib/DownloadMonitor.svelte
+++ /dev/null
@@ -1,201 +0,0 @@
-<script lang="ts">
- import { listen } from "@tauri-apps/api/event";
- import { onMount, onDestroy } from "svelte";
-
- export let visible = false;
-
- interface DownloadEvent {
- file: string;
- downloaded: number; // in bytes
- total: number; // in bytes
- status: string;
- completed_files: number;
- total_files: number;
- total_downloaded_bytes: number;
- }
-
- let currentFile = "";
- let progress = 0; // percentage 0-100 (current file)
- let totalProgress = 0; // percentage 0-100 (all files)
- let totalFiles = 0;
- let completedFiles = 0;
- let statusText = "Preparing...";
- let unlistenProgress: () => void;
- let unlistenStart: () => void;
- let unlistenComplete: () => void;
- let downloadedBytes = 0;
- let totalBytes = 0;
-
- // Speed and ETA tracking
- let downloadSpeed = 0; // bytes per second
- let etaSeconds = 0;
- let startTime = 0;
- let totalDownloadedBytes = 0;
- let lastUpdateTime = 0;
- let lastTotalBytes = 0;
-
- onMount(async () => {
- unlistenStart = await listen<number>("download-start", (event) => {
- visible = true;
- totalFiles = event.payload;
- completedFiles = 0;
- progress = 0;
- totalProgress = 0;
- statusText = "Starting download...";
- currentFile = "";
- // Reset speed tracking
- startTime = Date.now();
- totalDownloadedBytes = 0;
- downloadSpeed = 0;
- etaSeconds = 0;
- lastUpdateTime = Date.now();
- lastTotalBytes = 0;
- });
-
- unlistenProgress = await listen<DownloadEvent>(
- "download-progress",
- (event) => {
- const payload = event.payload;
- currentFile = payload.file;
-
- // Current file progress
- downloadedBytes = payload.downloaded;
- totalBytes = payload.total;
-
- statusText = payload.status;
-
- if (payload.total > 0) {
- progress = (payload.downloaded / payload.total) * 100;
- }
-
- // Total progress (all files)
- completedFiles = payload.completed_files;
- totalFiles = payload.total_files;
- if (totalFiles > 0) {
- const currentFileFraction =
- payload.total > 0 ? payload.downloaded / payload.total : 0;
- totalProgress = ((completedFiles + currentFileFraction) / totalFiles) * 100;
- }
-
- // Calculate download speed (using moving average)
- totalDownloadedBytes = payload.total_downloaded_bytes;
- const now = Date.now();
- const timeDiff = (now - lastUpdateTime) / 1000; // seconds
-
- if (timeDiff >= 0.5) { // Update speed every 0.5 seconds
- const bytesDiff = totalDownloadedBytes - lastTotalBytes;
- const instantSpeed = bytesDiff / timeDiff;
- // Smooth the speed with exponential moving average
- downloadSpeed = downloadSpeed === 0 ? instantSpeed : downloadSpeed * 0.7 + instantSpeed * 0.3;
- lastUpdateTime = now;
- lastTotalBytes = totalDownloadedBytes;
- }
-
- // Estimate remaining time
- if (downloadSpeed > 0 && completedFiles < totalFiles) {
- const remainingFiles = totalFiles - completedFiles;
- let estimatedRemainingBytes: number;
-
- if (completedFiles > 0) {
- // Use average size of completed files to estimate remaining files
- const avgBytesPerCompletedFile = totalDownloadedBytes / completedFiles;
- estimatedRemainingBytes = avgBytesPerCompletedFile * remainingFiles;
- } else {
- // No completed files yet: estimate based only on current file's remaining bytes
- estimatedRemainingBytes = Math.max(totalBytes - downloadedBytes, 0);
- }
- etaSeconds = estimatedRemainingBytes / downloadSpeed;
- } else {
- etaSeconds = 0;
- }
- }
- );
-
- unlistenComplete = await listen("download-complete", () => {
- statusText = "Done!";
- progress = 100;
- totalProgress = 100;
- setTimeout(() => {
- visible = false;
- }, 2000);
- });
- });
-
- onDestroy(() => {
- if (unlistenProgress) unlistenProgress();
- if (unlistenStart) unlistenStart();
- if (unlistenComplete) unlistenComplete();
- });
-
- function formatBytes(bytes: number) {
- if (bytes === 0) return "0 B";
- const k = 1024;
- const sizes = ["B", "KB", "MB", "GB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
- }
-
- function formatSpeed(bytesPerSecond: number) {
- if (bytesPerSecond === 0) return "-- /s";
- return formatBytes(bytesPerSecond) + "/s";
- }
-
- function formatTime(seconds: number) {
- if (seconds <= 0 || !isFinite(seconds)) return "--";
- if (seconds < 60) return `${Math.round(seconds)}s`;
- if (seconds < 3600) {
- const mins = Math.floor(seconds / 60);
- const secs = Math.round(seconds % 60);
- return `${mins}m ${secs}s`;
- }
- const hours = Math.floor(seconds / 3600);
- const mins = Math.floor((seconds % 3600) / 60);
- return `${hours}h ${mins}m`;
- }
-</script>
-
-{#if visible}
- <div
- class="fixed bottom-28 right-8 z-50 w-80 bg-zinc-900/90 border border-zinc-700 rounded-lg shadow-2xl p-4 animate-in slide-in-from-right-10 fade-in duration-300"
- >
- <div class="flex items-center justify-between mb-2">
- <h3 class="text-white font-bold text-sm">Downloads</h3>
- <span class="text-xs text-zinc-400">{statusText}</span>
- </div>
-
- <!-- Total Progress Bar -->
- <div class="mb-3">
- <div class="flex justify-between text-[10px] text-zinc-400 mb-1">
- <span>Total Progress</span>
- <span>{completedFiles} / {totalFiles} files</span>
- </div>
- <div class="w-full bg-zinc-800 rounded-full h-2.5 overflow-hidden">
- <div
- class="bg-gradient-to-r from-blue-500 to-cyan-400 h-2.5 rounded-full transition-all duration-200"
- style="width: {totalProgress}%"
- ></div>
- </div>
- <div class="flex justify-between text-[10px] text-zinc-500 font-mono mt-0.5">
- <span>{formatSpeed(downloadSpeed)} · ETA: {formatTime(etaSeconds)}</span>
- <span>{completedFiles < totalFiles ? Math.floor(totalProgress) : 100}%</span>
- </div>
- </div>
-
- <div class="text-xs text-zinc-300 truncate mb-1" title={currentFile}>
- {currentFile || "Waiting..."}
- </div>
-
- <!-- Current File Progress Bar -->
- <div class="w-full bg-zinc-800 rounded-full h-2 mb-2 overflow-hidden">
- <div
- class="bg-gradient-to-r from-green-500 to-emerald-400 h-2 rounded-full transition-all duration-200"
- style="width: {progress}%"
- ></div>
- </div>
-
- <div class="flex justify-between text-[10px] text-zinc-500 font-mono">
- <span>{formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}</span>
- <span>{Math.round(progress)}%</span>
- </div>
- </div>
-{/if}
diff --git a/packages/ui/src/lib/GameConsole.svelte b/packages/ui/src/lib/GameConsole.svelte
deleted file mode 100644
index bc5edbc..0000000
--- a/packages/ui/src/lib/GameConsole.svelte
+++ /dev/null
@@ -1,304 +0,0 @@
-<script lang="ts">
- import { logsState, type LogEntry } from "../stores/logs.svelte";
- import { uiState } from "../stores/ui.svelte";
- import { save } from "@tauri-apps/plugin-dialog";
- import { writeTextFile } from "@tauri-apps/plugin-fs";
- import { invoke } from "@tauri-apps/api/core";
- import { open } from "@tauri-apps/plugin-shell";
- import { onMount, tick } from "svelte";
- import CustomSelect from "../components/CustomSelect.svelte";
- import { ChevronDown, Check } from 'lucide-svelte';
-
- let consoleElement: HTMLDivElement;
- let autoScroll = $state(true);
-
- // Search & Filter
- let searchQuery = $state("");
- let showInfo = $state(true);
- let showWarn = $state(true);
- let showError = $state(true);
- let showDebug = $state(false);
-
- // Source filter: "all" or specific source name
- let selectedSource = $state("all");
-
- // Get sorted sources for dropdown
- let sourceOptions = $derived([
- { value: "all", label: "All Sources" },
- ...[...logsState.sources].sort().map(s => ({ value: s, label: s }))
- ]);
-
- // Derived filtered logs
- let filteredLogs = $derived(logsState.logs.filter((log) => {
- // Source Filter
- if (selectedSource !== "all" && log.source !== selectedSource) return false;
-
- // Level Filter
- if (!showInfo && log.level === "info") return false;
- if (!showWarn && log.level === "warn") return false;
- if (!showError && (log.level === "error" || log.level === "fatal")) return false;
- if (!showDebug && log.level === "debug") return false;
-
- // Search Filter
- if (searchQuery) {
- const q = searchQuery.toLowerCase();
- return (
- log.message.toLowerCase().includes(q) ||
- log.source.toLowerCase().includes(q)
- );
- }
- return true;
- }));
-
- // Auto-scroll logic
- $effect(() => {
- // Depend on filteredLogs length to trigger scroll
- if (filteredLogs.length && autoScroll && consoleElement) {
- // Use tick to wait for DOM update
- tick().then(() => {
- consoleElement.scrollTop = consoleElement.scrollHeight;
- });
- }
- });
-
- function handleScroll() {
- if (!consoleElement) return;
- const { scrollTop, scrollHeight, clientHeight } = consoleElement;
- // If user scrolls up (more than 50px from bottom), disable auto-scroll
- if (scrollHeight - scrollTop - clientHeight > 50) {
- autoScroll = false;
- } else {
- autoScroll = true;
- }
- }
-
- // Export only currently filtered logs
- async function exportLogs() {
- try {
- const content = logsState.exportLogs(filteredLogs);
- const path = await save({
- filters: [{ name: "Log File", extensions: ["txt", "log"] }],
- defaultPath: `dropout-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.log`,
- });
-
- if (path) {
- await writeTextFile(path, content);
- logsState.addLog("info", "Console", `Exported ${filteredLogs.length} logs to ${path}`);
- }
- } catch (e) {
- console.error("Export failed", e);
- logsState.addLog("error", "Console", `Export failed: ${e}`);
- }
- }
-
- // Upload only currently filtered logs
- async function uploadLogs() {
- try {
- const content = logsState.exportLogs(filteredLogs);
- logsState.addLog("info", "Console", `Uploading ${filteredLogs.length} logs...`);
-
- const response = await invoke<{ url: string }>("upload_to_pastebin", { content });
-
- logsState.addLog("info", "Console", `Logs uploaded successfully: ${response.url}`);
- await open(response.url);
- } catch (e) {
- console.error("Upload failed", e);
- logsState.addLog("error", "Console", `Upload failed: ${e}`);
- }
- }
-
- function highlightText(text: string, query: string) {
- if (!query) return text;
- // Escape regex special chars in query
- const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const parts = text.split(new RegExp(`(${escaped})`, "gi"));
- return parts.map(part =>
- part.toLowerCase() === query.toLowerCase()
- ? `<span class="bg-yellow-500/30 text-yellow-200 font-bold">${part}</span>`
- : part
- ).join("");
- }
-
- function getLevelColor(level: LogEntry["level"]) {
- switch (level) {
- case "info": return "text-blue-400";
- case "warn": return "text-yellow-400";
- case "error":
- case "fatal": return "text-red-400";
- case "debug": return "text-purple-400";
- default: return "text-zinc-400";
- }
- }
-
- function getLevelLabel(level: LogEntry["level"]) {
- switch (level) {
- case "info": return "INFO";
- case "warn": return "WARN";
- case "error": return "ERR";
- case "fatal": return "FATAL";
- case "debug": return "DEBUG";
- }
- }
-
- function getMessageColor(log: LogEntry) {
- if (log.level === "error" || log.level === "fatal") return "text-red-300";
- if (log.level === "warn") return "text-yellow-200";
- if (log.level === "debug") return "text-purple-200/70";
- if (log.source.startsWith("Game")) return "text-emerald-100/80";
- return "";
- }
-</script>
-
-<div class="absolute inset-0 flex flex-col bg-[#1e1e1e] text-zinc-300 font-mono text-xs overflow-hidden">
- <!-- Toolbar -->
- <div class="flex flex-wrap items-center justify-between p-2 bg-[#252526] border-b border-[#3e3e42] gap-2">
- <div class="flex items-center gap-3">
- <h3 class="font-bold text-zinc-100 uppercase tracking-wider px-2">Console</h3>
-
- <!-- Source Dropdown -->
- <CustomSelect
- options={sourceOptions}
- bind:value={selectedSource}
- class="w-36"
- />
-
- <!-- Level Filters -->
- <div class="flex items-center bg-[#1e1e1e] rounded border border-[#3e3e42] overflow-hidden">
- <button
- class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showInfo ? 'text-blue-400' : 'text-zinc-600'}"
- onclick={() => showInfo = !showInfo}
- title="Toggle Info"
- >Info</button>
- <div class="w-px h-3 bg-[#3e3e42]"></div>
- <button
- class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showWarn ? 'text-yellow-400' : 'text-zinc-600'}"
- onclick={() => showWarn = !showWarn}
- title="Toggle Warnings"
- >Warn</button>
- <div class="w-px h-3 bg-[#3e3e42]"></div>
- <button
- class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showError ? 'text-red-400' : 'text-zinc-600'}"
- onclick={() => showError = !showError}
- title="Toggle Errors"
- >Error</button>
- <div class="w-px h-3 bg-[#3e3e42]"></div>
- <button
- class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showDebug ? 'text-purple-400' : 'text-zinc-600'}"
- onclick={() => showDebug = !showDebug}
- title="Toggle Debug"
- >Debug</button>
- </div>
-
- <!-- Search -->
- <div class="relative group">
- <input
- type="text"
- bind:value={searchQuery}
- placeholder="Find..."
- class="bg-[#1e1e1e] border border-[#3e3e42] rounded pl-8 pr-2 py-1 focus:border-indigo-500 focus:outline-none w-40 text-zinc-300 placeholder:text-zinc-600 transition-all focus:w-64"
- />
- <svg class="w-3.5 h-3.5 text-zinc-500 absolute left-2.5 top-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
- {#if searchQuery}
- <button class="absolute right-2 top-1.5 text-zinc-500 hover:text-white" onclick={() => searchQuery = ""}>✕</button>
- {/if}
- </div>
- </div>
-
- <!-- Actions -->
- <div class="flex items-center gap-2">
- <!-- Log count indicator -->
- <span class="text-zinc-500 text-[10px] px-2">{filteredLogs.length} / {logsState.logs.length}</span>
-
- <button
- onclick={() => logsState.clear()}
- class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
- title="Clear Logs"
- >
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
- </button>
- <button
- onclick={exportLogs}
- class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
- title="Export Filtered Logs"
- >
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
- </button>
- <button
- onclick={uploadLogs}
- class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
- title="Upload Filtered Logs"
- >
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
- </button>
- <div class="w-px h-4 bg-[#3e3e42] mx-1"></div>
- <button
- onclick={() => uiState.toggleConsole()}
- class="p-1.5 hover:bg-red-500/20 hover:text-red-400 rounded text-zinc-400 transition-colors"
- title="Close"
- >
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
- </button>
- </div>
- </div>
-
- <!-- Log Area -->
- <div
- bind:this={consoleElement}
- onscroll={handleScroll}
- class="flex-1 overflow-y-auto overflow-x-hidden p-2 select-text custom-scrollbar"
- >
- {#each filteredLogs as log (log.id)}
- <div class="flex gap-2 leading-relaxed hover:bg-[#2a2d2e] px-1 rounded-sm group">
- <!-- Timestamp -->
- <span class="text-zinc-500 shrink-0 select-none w-20 text-right opacity-50 group-hover:opacity-100">{log.timestamp.split('.')[0]}</span>
-
- <!-- Source & Level -->
- <div class="flex shrink-0 min-w-[140px] gap-1 justify-end font-bold select-none truncate">
- <span class="text-zinc-500 truncate max-w-[90px]" title={log.source}>{log.source}</span>
- <span class={getLevelColor(log.level)}>{getLevelLabel(log.level)}</span>
- </div>
-
- <!-- Message -->
- <div class="flex-1 break-all whitespace-pre-wrap text-zinc-300 min-w-0 {getMessageColor(log)}">
- {@html highlightText(log.message, searchQuery)}
- </div>
- </div>
- {/each}
-
- {#if filteredLogs.length === 0}
- <div class="text-center text-zinc-600 mt-10 italic select-none">
- {#if logsState.logs.length === 0}
- Waiting for logs...
- {:else}
- No logs match current filters.
- {/if}
- </div>
- {/if}
- </div>
-
- <!-- Auto-scroll status -->
- {#if !autoScroll}
- <button
- onclick={() => { autoScroll = true; consoleElement.scrollTop = consoleElement.scrollHeight; }}
- class="absolute bottom-4 right-6 bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded shadow-lg text-xs font-bold transition-all animate-bounce"
- >
- Resume Auto-scroll ⬇
- </button>
- {/if}
-</div>
-
-<style>
- /* Custom Scrollbar for the log area */
- .custom-scrollbar::-webkit-scrollbar {
- width: 10px;
- background-color: #1e1e1e;
- }
- .custom-scrollbar::-webkit-scrollbar-thumb {
- background-color: #424242;
- border: 2px solid #1e1e1e; /* padding around thumb */
- border-radius: 0;
- }
- .custom-scrollbar::-webkit-scrollbar-thumb:hover {
- background-color: #4f4f4f;
- }
-</style>
diff --git a/packages/ui/src/lib/effects/ConstellationEffect.ts b/packages/ui/src/lib/effects/ConstellationEffect.ts
deleted file mode 100644
index d2db529..0000000
--- a/packages/ui/src/lib/effects/ConstellationEffect.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-export class ConstellationEffect {
- private canvas: HTMLCanvasElement;
- private ctx: CanvasRenderingContext2D;
- private width: number = 0;
- private height: number = 0;
- private particles: Particle[] = [];
- private animationId: number = 0;
- private mouseX: number = -1000;
- private mouseY: number = -1000;
-
- // Configuration
- private readonly particleCount = 100;
- private readonly connectionDistance = 150;
- private readonly particleSpeed = 0.5;
-
- constructor(canvas: HTMLCanvasElement) {
- this.canvas = canvas;
- this.ctx = canvas.getContext("2d", { alpha: true })!;
-
- // Bind methods
- this.animate = this.animate.bind(this);
- this.handleMouseMove = this.handleMouseMove.bind(this);
-
- // Initial setup
- this.resize(window.innerWidth, window.innerHeight);
- this.initParticles();
-
- // Mouse interaction
- window.addEventListener("mousemove", this.handleMouseMove);
-
- // Start animation
- this.animate();
- }
-
- resize(width: number, height: number) {
- const dpr = window.devicePixelRatio || 1;
- this.width = width;
- this.height = height;
-
- this.canvas.width = width * dpr;
- this.canvas.height = height * dpr;
- this.canvas.style.width = `${width}px`;
- this.canvas.style.height = `${height}px`;
-
- this.ctx.scale(dpr, dpr);
-
- // Re-initialize if screen size changes significantly to maintain density
- if (this.particles.length === 0) {
- this.initParticles();
- }
- }
-
- private initParticles() {
- this.particles = [];
- // Adjust density based on screen area
- const area = this.width * this.height;
- const density = Math.floor(area / 15000); // 1 particle per 15000px²
- const count = Math.min(Math.max(density, 50), 200); // Clamp between 50 and 200
-
- for (let i = 0; i < count; i++) {
- this.particles.push(new Particle(this.width, this.height, this.particleSpeed));
- }
- }
-
- private handleMouseMove(e: MouseEvent) {
- const rect = this.canvas.getBoundingClientRect();
- this.mouseX = e.clientX - rect.left;
- this.mouseY = e.clientY - rect.top;
- }
-
- animate() {
- this.ctx.clearRect(0, 0, this.width, this.height);
-
- // Update and draw particles
- this.particles.forEach((p) => {
- p.update(this.width, this.height);
- p.draw(this.ctx);
- });
-
- // Draw lines
- this.drawConnections();
-
- this.animationId = requestAnimationFrame(this.animate);
- }
-
- private drawConnections() {
- this.ctx.lineWidth = 1;
-
- for (let i = 0; i < this.particles.length; i++) {
- const p1 = this.particles[i];
-
- // Connect to mouse if close
- const distMouse = Math.hypot(p1.x - this.mouseX, p1.y - this.mouseY);
- if (distMouse < this.connectionDistance + 50) {
- const alpha = 1 - distMouse / (this.connectionDistance + 50);
- this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.4})`; // Brighter near mouse
- this.ctx.beginPath();
- this.ctx.moveTo(p1.x, p1.y);
- this.ctx.lineTo(this.mouseX, this.mouseY);
- this.ctx.stroke();
-
- // Gently attract to mouse
- if (distMouse > 10) {
- p1.x += (this.mouseX - p1.x) * 0.005;
- p1.y += (this.mouseY - p1.y) * 0.005;
- }
- }
-
- // Connect to other particles
- for (let j = i + 1; j < this.particles.length; j++) {
- const p2 = this.particles[j];
- const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
-
- if (dist < this.connectionDistance) {
- const alpha = 1 - dist / this.connectionDistance;
- this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.15})`;
- this.ctx.beginPath();
- this.ctx.moveTo(p1.x, p1.y);
- this.ctx.lineTo(p2.x, p2.y);
- this.ctx.stroke();
- }
- }
- }
- }
-
- destroy() {
- cancelAnimationFrame(this.animationId);
- window.removeEventListener("mousemove", this.handleMouseMove);
- }
-}
-
-class Particle {
- x: number;
- y: number;
- vx: number;
- vy: number;
- size: number;
-
- constructor(w: number, h: number, speed: number) {
- this.x = Math.random() * w;
- this.y = Math.random() * h;
- this.vx = (Math.random() - 0.5) * speed;
- this.vy = (Math.random() - 0.5) * speed;
- this.size = Math.random() * 2 + 1;
- }
-
- update(w: number, h: number) {
- this.x += this.vx;
- this.y += this.vy;
-
- // Bounce off walls
- if (this.x < 0 || this.x > w) this.vx *= -1;
- if (this.y < 0 || this.y > h) this.vy *= -1;
- }
-
- draw(ctx: CanvasRenderingContext2D) {
- ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
- ctx.beginPath();
- ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
- ctx.fill();
- }
-}
diff --git a/packages/ui/src/lib/effects/SaturnEffect.ts b/packages/ui/src/lib/effects/SaturnEffect.ts
index 357da9d..497a340 100644
--- a/packages/ui/src/lib/effects/SaturnEffect.ts
+++ b/packages/ui/src/lib/effects/SaturnEffect.ts
@@ -1,46 +1,61 @@
-// Optimized Saturn Effect for low-end hardware
-// Uses TypedArrays for memory efficiency and reduced particle density
+/**
+ * Ported SaturnEffect for the React UI (ui-new).
+ * Adapted from the original Svelte implementation but written as a standalone
+ * TypeScript class that manages a 2D canvas particle effect resembling a
+ * rotating "Saturn" with rings. Designed to be instantiated and controlled
+ * from a React component (e.g. ParticleBackground).
+ *
+ * Usage:
+ * const effect = new SaturnEffect(canvasElement);
+ * effect.handleMouseDown(clientX);
+ * effect.handleMouseMove(clientX);
+ * effect.handleMouseUp();
+ * // on resize:
+ * effect.resize(width, height);
+ * // on unmount:
+ * effect.destroy();
+ */
export class SaturnEffect {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
- private width: number = 0;
- private height: number = 0;
-
- // Data-oriented design for performance
- // xyz: Float32Array where [i*3, i*3+1, i*3+2] corresponds to x, y, z
- private xyz: Float32Array | null = null;
- // types: Uint8Array where 0 = planet, 1 = ring
- private types: Uint8Array | null = null;
- private count: number = 0;
-
- private animationId: number = 0;
- private angle: number = 0;
- private scaleFactor: number = 1;
-
- // Mouse interaction properties
- private isDragging: boolean = false;
- private lastMouseX: number = 0;
- private lastMouseTime: number = 0;
- private mouseVelocities: number[] = []; // Store recent velocities for averaging
-
- // Rotation speed control
- private readonly baseSpeed: number = 0.005; // Original rotation speed
- private currentSpeed: number = 0.005; // Current rotation speed (can be modified by mouse)
- private rotationDirection: number = 1; // 1 for clockwise, -1 for counter-clockwise
- private readonly speedDecayRate: number = 0.992; // How fast speed returns to normal (closer to 1 = slower decay)
- private readonly minSpeedMultiplier: number = 1; // Minimum speed is baseSpeed
- private readonly maxSpeedMultiplier: number = 50; // Maximum speed is 50x baseSpeed
- private isStopped: boolean = false; // Whether the user has stopped the rotation
+ private width = 0;
+ private height = 0;
+
+ // Particle storage
+ private xyz: Float32Array | null = null; // interleaved x,y,z
+ private types: Uint8Array | null = null; // 0 = planet, 1 = ring
+ private count = 0;
+
+ // Animation
+ private animationId = 0;
+ private angle = 0;
+ private scaleFactor = 1;
+
+ // Interaction
+ private isDragging = false;
+ private lastMouseX = 0;
+ private lastMouseTime = 0;
+ private mouseVelocities: number[] = [];
+
+ // Speed control
+ private readonly baseSpeed = 0.005;
+ private currentSpeed = 0.005;
+ private rotationDirection = 1;
+ private readonly speedDecayRate = 0.992;
+ private readonly minSpeedMultiplier = 1;
+ private readonly maxSpeedMultiplier = 50;
+ private isStopped = false;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
- this.ctx = canvas.getContext("2d", {
- alpha: true,
- desynchronized: false, // default is usually fine, 'desynchronized' can help latency but might flicker
- })!;
+ const ctx = canvas.getContext("2d", { alpha: true, desynchronized: false });
+ if (!ctx) {
+ throw new Error("Failed to get 2D context for SaturnEffect");
+ }
+ this.ctx = ctx;
- // Initial resize will set up everything
+ // Initialize size & particles
this.resize(window.innerWidth, window.innerHeight);
this.initParticles();
@@ -48,9 +63,7 @@ export class SaturnEffect {
this.animate();
}
- // Public methods for external mouse event handling
- // These can be called from any element that wants to control the Saturn rotation
-
+ // External interaction handlers (accept clientX)
handleMouseDown(clientX: number) {
this.isDragging = true;
this.lastMouseX = clientX;
@@ -60,26 +73,18 @@ export class SaturnEffect {
handleMouseMove(clientX: number) {
if (!this.isDragging) return;
-
- const currentTime = performance.now();
- const deltaTime = currentTime - this.lastMouseTime;
-
- if (deltaTime > 0) {
- const deltaX = clientX - this.lastMouseX;
- const velocity = deltaX / deltaTime; // pixels per millisecond
-
- // Store recent velocities (keep last 5 for smoothing)
+ const now = performance.now();
+ const dt = now - this.lastMouseTime;
+ if (dt > 0) {
+ const dx = clientX - this.lastMouseX;
+ const velocity = dx / dt;
this.mouseVelocities.push(velocity);
- if (this.mouseVelocities.length > 5) {
- this.mouseVelocities.shift();
- }
-
- // Apply direct rotation while dragging
- this.angle += deltaX * 0.002;
+ if (this.mouseVelocities.length > 5) this.mouseVelocities.shift();
+ // Rotate directly while dragging for immediate feedback
+ this.angle += dx * 0.002;
}
-
this.lastMouseX = clientX;
- this.lastMouseTime = currentTime;
+ this.lastMouseTime = now;
}
handleMouseUp() {
@@ -90,174 +95,130 @@ export class SaturnEffect {
}
handleTouchStart(clientX: number) {
- this.isDragging = true;
- this.lastMouseX = clientX;
- this.lastMouseTime = performance.now();
- this.mouseVelocities = [];
+ this.handleMouseDown(clientX);
}
handleTouchMove(clientX: number) {
- if (!this.isDragging) return;
-
- const currentTime = performance.now();
- const deltaTime = currentTime - this.lastMouseTime;
-
- if (deltaTime > 0) {
- const deltaX = clientX - this.lastMouseX;
- const velocity = deltaX / deltaTime;
-
- this.mouseVelocities.push(velocity);
- if (this.mouseVelocities.length > 5) {
- this.mouseVelocities.shift();
- }
-
- this.angle += deltaX * 0.002;
- }
-
- this.lastMouseX = clientX;
- this.lastMouseTime = currentTime;
+ this.handleMouseMove(clientX);
}
handleTouchEnd() {
- if (this.isDragging && this.mouseVelocities.length > 0) {
- this.applyFlingVelocity();
- }
- this.isDragging = false;
- }
-
- private applyFlingVelocity() {
- // Calculate average velocity from recent samples
- const avgVelocity =
- this.mouseVelocities.reduce((a, b) => a + b, 0) / this.mouseVelocities.length;
-
- // Threshold for considering it a "fling" (pixels per millisecond)
- const flingThreshold = 0.3;
- // Threshold for considering the rotation as "stopped" by user
- const stopThreshold = 0.1;
-
- if (Math.abs(avgVelocity) > flingThreshold) {
- // User flung it - start rotating again
- this.isStopped = false;
-
- // Determine new direction based on fling direction
- const newDirection = avgVelocity > 0 ? 1 : -1;
-
- // If direction changed, update it permanently
- if (newDirection !== this.rotationDirection) {
- this.rotationDirection = newDirection;
- }
-
- // Calculate speed boost based on fling strength
- // Map velocity to speed multiplier (stronger fling = faster rotation)
- const speedMultiplier = Math.min(
- this.maxSpeedMultiplier,
- this.minSpeedMultiplier + Math.abs(avgVelocity) * 10,
- );
-
- this.currentSpeed = this.baseSpeed * speedMultiplier;
- } else if (Math.abs(avgVelocity) < stopThreshold) {
- // User gently released - keep it stopped
- this.isStopped = true;
- this.currentSpeed = 0;
- }
- // If velocity is between stopThreshold and flingThreshold,
- // keep current state (don't change isStopped)
+ this.handleMouseUp();
}
+ // Resize canvas & scale (call on window resize)
resize(width: number, height: number) {
const dpr = window.devicePixelRatio || 1;
this.width = width;
this.height = height;
- this.canvas.width = width * dpr;
- this.canvas.height = height * dpr;
+ // Update canvas pixel size and CSS size
+ this.canvas.width = Math.max(1, Math.floor(width * dpr));
+ this.canvas.height = Math.max(1, Math.floor(height * dpr));
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
+ // Reset transform and scale for devicePixelRatio
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0); // reset
this.ctx.scale(dpr, dpr);
- // Dynamic scaling based on screen size
const minDim = Math.min(width, height);
- this.scaleFactor = minDim * 0.45;
+ this.scaleFactor = Math.max(1, minDim * 0.45);
}
- initParticles() {
- // Significantly reduced particle count for CPU optimization
- // Planet: 1800 -> 1000
- // Rings: 5000 -> 2500
- // Total approx 3500 vs 6800 previously (approx 50% reduction)
+ // Initialize particle arrays with reduced counts to keep performance reasonable
+ private initParticles() {
+ // Tuned particle counts for reasonable performance across platforms
const planetCount = 1000;
const ringCount = 2500;
this.count = planetCount + ringCount;
- // Use TypedArrays for better memory locality
this.xyz = new Float32Array(this.count * 3);
this.types = new Uint8Array(this.count);
let idx = 0;
- // 1. Planet
- for (let i = 0; i < planetCount; i++) {
+ // Planet points
+ for (let i = 0; i < planetCount; i++, idx++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(Math.random() * 2 - 1);
const r = 1.0;
- // x, y, z
this.xyz[idx * 3] = r * Math.sin(phi) * Math.cos(theta);
this.xyz[idx * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
this.xyz[idx * 3 + 2] = r * Math.cos(phi);
- this.types[idx] = 0; // 0 for planet
- idx++;
+ this.types[idx] = 0;
}
- // 2. Rings
+ // Ring points
const ringInner = 1.4;
const ringOuter = 2.3;
-
- for (let i = 0; i < ringCount; i++) {
+ for (let i = 0; i < ringCount; i++, idx++) {
const angle = Math.random() * Math.PI * 2;
const dist = Math.sqrt(
- Math.random() * (ringOuter * ringOuter - ringInner * ringInner) + ringInner * ringInner,
+ Math.random() * (ringOuter * ringOuter - ringInner * ringInner) +
+ ringInner * ringInner,
);
- // x, y, z
this.xyz[idx * 3] = dist * Math.cos(angle);
this.xyz[idx * 3 + 1] = (Math.random() - 0.5) * 0.05;
this.xyz[idx * 3 + 2] = dist * Math.sin(angle);
- this.types[idx] = 1; // 1 for ring
- idx++;
+ this.types[idx] = 1;
}
}
- animate() {
+ // Map fling/velocity samples to a rotation speed and direction
+ private applyFlingVelocity() {
+ if (this.mouseVelocities.length === 0) return;
+ const avg =
+ this.mouseVelocities.reduce((a, b) => a + b, 0) /
+ this.mouseVelocities.length;
+ const flingThreshold = 0.3;
+ const stopThreshold = 0.1;
+
+ if (Math.abs(avg) > flingThreshold) {
+ this.isStopped = false;
+ const newDir = avg > 0 ? 1 : -1;
+ if (newDir !== this.rotationDirection) this.rotationDirection = newDir;
+ const multiplier = Math.min(
+ this.maxSpeedMultiplier,
+ this.minSpeedMultiplier + Math.abs(avg) * 10,
+ );
+ this.currentSpeed = this.baseSpeed * multiplier;
+ } else if (Math.abs(avg) < stopThreshold) {
+ this.isStopped = true;
+ this.currentSpeed = 0;
+ }
+ }
+
+ // Main render loop
+ private animate() {
+ // Clear with full alpha to allow layering over background
this.ctx.clearRect(0, 0, this.width, this.height);
- // Normal blending
+ // Standard composition
this.ctx.globalCompositeOperation = "source-over";
- // Update rotation speed - decay towards base speed while maintaining direction
+ // Update rotation speed (decay)
if (!this.isDragging && !this.isStopped) {
if (this.currentSpeed > this.baseSpeed) {
- // Gradually decay speed back to base speed
this.currentSpeed =
- this.baseSpeed + (this.currentSpeed - this.baseSpeed) * this.speedDecayRate;
-
- // Snap to base speed when close enough
+ this.baseSpeed +
+ (this.currentSpeed - this.baseSpeed) * this.speedDecayRate;
if (this.currentSpeed - this.baseSpeed < 0.00001) {
this.currentSpeed = this.baseSpeed;
}
}
-
- // Apply rotation with current speed and direction
this.angle += this.currentSpeed * this.rotationDirection;
}
+ // Center positions
const cx = this.width * 0.6;
const cy = this.height * 0.5;
- // Pre-calculate rotation matrices
+ // Pre-calc rotations
const rotationY = this.angle;
const rotationX = 0.4;
const rotationZ = 0.15;
@@ -272,29 +233,27 @@ export class SaturnEffect {
const fov = 1500;
const scaleFactor = this.scaleFactor;
- if (!this.xyz || !this.types) return;
+ if (!this.xyz || !this.types) {
+ this.animationId = requestAnimationFrame(this.animate);
+ return;
+ }
+ // Loop particles
for (let i = 0; i < this.count; i++) {
const x = this.xyz[i * 3];
const y = this.xyz[i * 3 + 1];
const z = this.xyz[i * 3 + 2];
- // Apply Scale
+ // Scale to screen
const px = x * scaleFactor;
const py = y * scaleFactor;
const pz = z * scaleFactor;
- // 1. Rotate Y
+ // Rotate Y then X then Z
const x1 = px * cosY - pz * sinY;
const z1 = pz * cosY + px * sinY;
- // y1 = py
-
- // 2. Rotate X
const y2 = py * cosX - z1 * sinX;
const z2 = z1 * cosX + py * sinX;
- // x2 = x1
-
- // 3. Rotate Z
const x3 = x1 * cosZ - y2 * sinZ;
const y3 = y2 * cosZ + x1 * sinZ;
const z3 = z2;
@@ -305,28 +264,23 @@ export class SaturnEffect {
const x2d = cx + x3 * scale;
const y2d = cy + y3 * scale;
- // Size calculation - slightly larger dots to compensate for lower count
- // Previously Planet 2.0 -> 2.4, Ring 1.3 -> 1.5
const type = this.types[i];
const sizeBase = type === 0 ? 2.4 : 1.5;
const size = sizeBase * scale;
- // Opacity
let alpha = scale * scale * scale;
if (alpha > 1) alpha = 1;
- if (alpha < 0.15) continue; // Skip very faint particles for performance
+ if (alpha < 0.15) continue;
- // Optimization: Planet color vs Ring color
if (type === 0) {
- // Planet: Warm White
+ // Planet: warm-ish
this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`;
} else {
- // Ring: Cool White
+ // Ring: cool-ish
this.ctx.fillStyle = `rgba(220, 240, 255, ${alpha})`;
}
- // Render as squares (fillRect) instead of circles (arc)
- // This is significantly faster for software rendering and reduces GPU usage.
+ // Render as small rectangles (faster than arc)
this.ctx.fillRect(x2d, y2d, size, size);
}
}
@@ -334,7 +288,12 @@ export class SaturnEffect {
this.animationId = requestAnimationFrame(this.animate);
}
+ // Stop animations and release resources
destroy() {
- cancelAnimationFrame(this.animationId);
+ if (this.animationId) {
+ cancelAnimationFrame(this.animationId);
+ this.animationId = 0;
+ }
+ // Intentionally do not null out arrays to allow reuse if desired.
}
}
diff --git a/packages/ui/src/lib/modLoaderApi.ts b/packages/ui/src/lib/modLoaderApi.ts
deleted file mode 100644
index 75f404a..0000000
--- a/packages/ui/src/lib/modLoaderApi.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * Mod Loader API service for Fabric and Forge integration.
- * This module provides functions to interact with the Tauri backend
- * for mod loader version management.
- */
-
-import { invoke } from "@tauri-apps/api/core";
-import type {
- FabricGameVersion,
- FabricLoaderVersion,
- FabricLoaderEntry,
- InstalledFabricVersion,
- ForgeVersion,
- InstalledForgeVersion,
-} from "../types";
-
-// ==================== Fabric API ====================
-
-/**
- * Get all Minecraft versions supported by Fabric.
- */
-export async function getFabricGameVersions(): Promise<FabricGameVersion[]> {
- return invoke<FabricGameVersion[]>("get_fabric_game_versions");
-}
-
-/**
- * Get all available Fabric loader versions.
- */
-export async function getFabricLoaderVersions(): Promise<FabricLoaderVersion[]> {
- return invoke<FabricLoaderVersion[]>("get_fabric_loader_versions");
-}
-
-/**
- * Get Fabric loaders available for a specific Minecraft version.
- */
-export async function getFabricLoadersForVersion(
- gameVersion: string,
-): Promise<FabricLoaderEntry[]> {
- return invoke<FabricLoaderEntry[]>("get_fabric_loaders_for_version", {
- gameVersion,
- });
-}
-
-/**
- * Install Fabric loader for a specific Minecraft version.
- */
-export async function installFabric(
- gameVersion: string,
- loaderVersion: string,
-): Promise<InstalledFabricVersion> {
- return invoke<InstalledFabricVersion>("install_fabric", {
- gameVersion,
- loaderVersion,
- });
-}
-
-/**
- * List all installed Fabric versions.
- */
-export async function listInstalledFabricVersions(): Promise<string[]> {
- return invoke<string[]>("list_installed_fabric_versions");
-}
-
-/**
- * Check if Fabric is installed for a specific version combination.
- */
-export async function isFabricInstalled(
- gameVersion: string,
- loaderVersion: string,
-): Promise<boolean> {
- return invoke<boolean>("is_fabric_installed", {
- gameVersion,
- loaderVersion,
- });
-}
-
-// ==================== Forge API ====================
-
-/**
- * Get all Minecraft versions supported by Forge.
- */
-export async function getForgeGameVersions(): Promise<string[]> {
- return invoke<string[]>("get_forge_game_versions");
-}
-
-/**
- * Get Forge versions available for a specific Minecraft version.
- */
-export async function getForgeVersionsForGame(gameVersion: string): Promise<ForgeVersion[]> {
- return invoke<ForgeVersion[]>("get_forge_versions_for_game", {
- gameVersion,
- });
-}
-
-/**
- * Install Forge for a specific Minecraft version.
- */
-export async function installForge(
- gameVersion: string,
- forgeVersion: string,
-): Promise<InstalledForgeVersion> {
- return invoke<InstalledForgeVersion>("install_forge", {
- gameVersion,
- forgeVersion,
- });
-}
diff --git a/packages/ui/src/lib/tsrs-utils.ts b/packages/ui/src/lib/tsrs-utils.ts
new file mode 100644
index 0000000..f48f851
--- /dev/null
+++ b/packages/ui/src/lib/tsrs-utils.ts
@@ -0,0 +1,67 @@
+export type Maybe<T> = T | null | undefined;
+
+export function toNumber(
+ value: Maybe<number | bigint | string>,
+ fallback = 0,
+): number {
+ if (value === null || value === undefined) return fallback;
+
+ if (typeof value === "number") {
+ if (Number.isFinite(value)) return value;
+ return fallback;
+ }
+
+ if (typeof value === "bigint") {
+ // safe conversion for typical values (timestamps, sizes). Might overflow for huge bigint.
+ return Number(value);
+ }
+
+ if (typeof value === "string") {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : fallback;
+ }
+
+ return fallback;
+}
+
+/**
+ * Like `toNumber` but ensures non-negative result (clamps at 0).
+ */
+export function toNonNegativeNumber(
+ value: Maybe<number | bigint | string>,
+ fallback = 0,
+): number {
+ const n = toNumber(value, fallback);
+ return n < 0 ? 0 : n;
+}
+
+export function toDate(
+ value: Maybe<number | bigint | string>,
+ opts?: { isSeconds?: boolean },
+): Date | null {
+ if (value === null || value === undefined) return null;
+
+ const isSeconds = opts?.isSeconds ?? true;
+
+ // accept bigint, number, numeric string
+ const n = toNumber(value, NaN);
+ if (Number.isNaN(n)) return null;
+
+ const ms = isSeconds ? Math.floor(n) * 1000 : Math.floor(n);
+ return new Date(ms);
+}
+
+/**
+ * Convert a binding boolean-ish value (0/1, "true"/"false", boolean) to boolean.
+ */
+export function toBoolean(value: unknown, fallback = false): boolean {
+ if (value === null || value === undefined) return fallback;
+ if (typeof value === "boolean") return value;
+ if (typeof value === "number") return value !== 0;
+ if (typeof value === "string") {
+ const s = value.toLowerCase().trim();
+ if (s === "true" || s === "1") return true;
+ if (s === "false" || s === "0") return false;
+ }
+ return fallback;
+}
diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts
new file mode 100644
index 0000000..365058c
--- /dev/null
+++ b/packages/ui/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/packages/ui/src/main.ts b/packages/ui/src/main.ts
deleted file mode 100644
index d47b930..0000000
--- a/packages/ui/src/main.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { mount } from "svelte";
-import "./app.css";
-import App from "./App.svelte";
-
-const app = mount(App, {
- target: document.getElementById("app")!,
-});
-
-export default app;
diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx
new file mode 100644
index 0000000..a3157bd
--- /dev/null
+++ b/packages/ui/src/main.tsx
@@ -0,0 +1,38 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import { createHashRouter, RouterProvider } from "react-router";
+import { Toaster } from "./components/ui/sonner";
+import { HomeView } from "./pages/home-view";
+import { IndexPage } from "./pages/index";
+import { InstancesView } from "./pages/instances-view";
+import { SettingsPage } from "./pages/settings";
+
+const router = createHashRouter([
+ {
+ path: "/",
+ element: <IndexPage />,
+ children: [
+ {
+ index: true,
+ element: <HomeView />,
+ },
+ {
+ path: "instances",
+ element: <InstancesView />,
+ },
+ {
+ path: "settings",
+ element: <SettingsPage />,
+ },
+ ],
+ },
+]);
+
+const root = createRoot(document.getElementById("root") as HTMLElement);
+root.render(
+ <StrictMode>
+ <RouterProvider router={router} />
+ <Toaster />
+ </StrictMode>,
+);
diff --git a/packages/ui/src/models/auth.ts b/packages/ui/src/models/auth.ts
new file mode 100644
index 0000000..10b2a0d
--- /dev/null
+++ b/packages/ui/src/models/auth.ts
@@ -0,0 +1,142 @@
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { open } from "@tauri-apps/plugin-shell";
+import { Mutex } from "es-toolkit";
+import { toString as stringify } from "es-toolkit/compat";
+import { toast } from "sonner";
+import { create } from "zustand";
+import {
+ completeMicrosoftLogin,
+ getActiveAccount,
+ loginOffline,
+ logout,
+ startMicrosoftLogin,
+} from "@/client";
+import type { Account, DeviceCodeResponse } from "@/types";
+
+export interface AuthState {
+ account: Account | null;
+ loginMode: Account["type"] | null;
+ deviceCode: DeviceCodeResponse | null;
+ _pollingInterval: number | null;
+ _mutex: Mutex;
+ statusMessage: string | null;
+ _progressUnlisten: UnlistenFn | null;
+
+ init: () => Promise<void>;
+ setLoginMode: (mode: Account["type"] | null) => void;
+ loginOnline: (onSuccess?: () => void | Promise<void>) => Promise<void>;
+ _pollLoginStatus: (
+ deviceCode: string,
+ onSuccess?: () => void | Promise<void>,
+ ) => Promise<void>;
+ cancelLoginOnline: () => Promise<void>;
+ loginOffline: (username: string) => Promise<void>;
+ logout: () => Promise<void>;
+}
+
+export const useAuthStore = create<AuthState>((set, get) => ({
+ account: null,
+ loginMode: null,
+ deviceCode: null,
+ _pollingInterval: null,
+ statusMessage: null,
+ _progressUnlisten: null,
+ _mutex: new Mutex(),
+
+ init: async () => {
+ try {
+ const account = await getActiveAccount();
+ set({ account });
+ } catch (error) {
+ console.error("Failed to initialize auth store:", error);
+ }
+ },
+ setLoginMode: (mode) => set({ loginMode: mode }),
+ loginOnline: async (onSuccess) => {
+ const { _pollLoginStatus } = get();
+
+ set({ statusMessage: "Waiting for authorization..." });
+
+ try {
+ const unlisten = await listen("auth-progress", (event) => {
+ const message = event.payload;
+ console.log(message);
+ set({ statusMessage: stringify(message), _progressUnlisten: unlisten });
+ });
+ } catch (error) {
+ console.warn("Failed to attch auth-progress listener:", error);
+ toast.warning("Failed to attch auth-progress listener");
+ }
+
+ const deviceCode = await startMicrosoftLogin();
+ navigator.clipboard?.writeText(deviceCode.userCode).catch((err) => {
+ console.error("Failed to copy to clipboard:", err);
+ });
+ open(deviceCode.verificationUri).catch((err) => {
+ console.error("Failed to open browser:", err);
+ });
+ const ms = Number(deviceCode.interval) * 1000;
+ const interval = setInterval(() => {
+ _pollLoginStatus(deviceCode.deviceCode, onSuccess);
+ }, ms);
+ set({ _pollingInterval: interval, deviceCode });
+ },
+ _pollLoginStatus: async (deviceCode, onSuccess) => {
+ const { _pollingInterval, _mutex: mutex, _progressUnlisten } = get();
+ if (mutex.isLocked) return;
+ mutex.acquire();
+ try {
+ const account = await completeMicrosoftLogin(deviceCode);
+ clearInterval(_pollingInterval ?? undefined);
+ _progressUnlisten?.();
+ onSuccess?.();
+ set({ account, loginMode: "microsoft" });
+ } catch (error) {
+ if (error === "authorization_pending") {
+ console.log("Authorization pending...");
+ } else {
+ console.error("Failed to poll login status:", error);
+ toast.error("Failed to poll login status");
+ }
+ } finally {
+ mutex.release();
+ }
+ },
+ cancelLoginOnline: async () => {
+ const { account, logout, _pollingInterval, _progressUnlisten } = get();
+ clearInterval(_pollingInterval ?? undefined);
+ _progressUnlisten?.();
+ if (account) {
+ await logout();
+ }
+ set({
+ loginMode: null,
+ _pollingInterval: null,
+ statusMessage: null,
+ _progressUnlisten: null,
+ });
+ },
+ loginOffline: async (username: string) => {
+ const trimmedUsername = username.trim();
+ if (trimmedUsername.length === 0) {
+ throw new Error("Username cannot be empty");
+ }
+
+ try {
+ const account = await loginOffline(trimmedUsername);
+ set({ account, loginMode: "offline" });
+ } catch (error) {
+ console.error("Failed to login offline:", error);
+ toast.error("Failed to login offline");
+ }
+ },
+ logout: async () => {
+ try {
+ await logout();
+ set({ account: null });
+ } catch (error) {
+ console.error("Failed to logout:", error);
+ toast.error("Failed to logout");
+ }
+ },
+}));
diff --git a/packages/ui/src/models/instances.ts b/packages/ui/src/models/instances.ts
new file mode 100644
index 0000000..f434c7c
--- /dev/null
+++ b/packages/ui/src/models/instances.ts
@@ -0,0 +1,135 @@
+import { toast } from "sonner";
+import { create } from "zustand";
+import {
+ createInstance,
+ deleteInstance,
+ duplicateInstance,
+ getActiveInstance,
+ getInstance,
+ listInstances,
+ setActiveInstance,
+ updateInstance,
+} from "@/client";
+import type { Instance } from "@/types";
+
+interface InstancesState {
+ // State
+ instances: Instance[];
+ activeInstance: Instance | null;
+
+ // Actions
+ refresh: () => Promise<void>;
+ create: (name: string) => Promise<Instance | null>;
+ delete: (id: string) => Promise<void>;
+ update: (instance: Instance) => Promise<void>;
+ setActiveInstance: (instance: Instance) => Promise<void>;
+ duplicate: (id: string, newName: string) => Promise<Instance | null>;
+ getInstance: (id: string) => Promise<Instance | null>;
+}
+
+export const useInstancesStore = create<InstancesState>((set, get) => ({
+ // Initial state
+ instances: [],
+ activeInstance: null,
+
+ // Actions
+ refresh: async () => {
+ const { setActiveInstance } = get();
+ try {
+ const instances = await listInstances();
+ const active = await getActiveInstance();
+
+ if (!active && instances.length > 0) {
+ // If no active instance but instances exist, set the first one as active
+ await setActiveInstance(instances[0]);
+ }
+
+ set({ instances });
+ } catch (e) {
+ console.error("Failed to load instances:", e);
+ toast.error("Error loading instances");
+ }
+ },
+
+ create: async (name) => {
+ const { refresh } = get();
+ try {
+ const instance = await createInstance(name);
+ await refresh();
+ toast.success(`Instance "${name}" created successfully`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ toast.error("Error creating instance");
+ return null;
+ }
+ },
+
+ delete: async (id) => {
+ const { refresh, instances, activeInstance, setActiveInstance } = get();
+ try {
+ await deleteInstance(id);
+ await refresh();
+
+ // If deleted instance was active, set another as active
+ if (activeInstance?.id === id) {
+ if (instances.length > 0) {
+ await setActiveInstance(instances[0]);
+ } else {
+ set({ activeInstance: null });
+ }
+ }
+
+ toast.success("Instance deleted successfully");
+ } catch (e) {
+ console.error("Failed to delete instance:", e);
+ toast.error("Error deleting instance");
+ }
+ },
+
+ update: async (instance) => {
+ const { refresh } = get();
+ try {
+ await updateInstance(instance);
+ await refresh();
+ toast.success("Instance updated successfully");
+ } catch (e) {
+ console.error("Failed to update instance:", e);
+ toast.error("Error updating instance");
+ }
+ },
+
+ setActiveInstance: async (instance) => {
+ try {
+ await setActiveInstance(instance.id);
+ set({ activeInstance: instance });
+ toast.success("Active instance changed");
+ } catch (e) {
+ console.error("Failed to set active instance:", e);
+ toast.error("Error setting active instance");
+ }
+ },
+
+ duplicate: async (id, newName) => {
+ const { refresh } = get();
+ try {
+ const instance = await duplicateInstance(id, newName);
+ await refresh();
+ toast.success(`Instance duplicated as "${newName}"`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to duplicate instance:", e);
+ toast.error("Error duplicating instance");
+ return null;
+ }
+ },
+
+ getInstance: async (id) => {
+ try {
+ return await getInstance(id);
+ } catch (e) {
+ console.error("Failed to get instance:", e);
+ return null;
+ }
+ },
+}));
diff --git a/packages/ui/src/models/settings.ts b/packages/ui/src/models/settings.ts
new file mode 100644
index 0000000..9f4119c
--- /dev/null
+++ b/packages/ui/src/models/settings.ts
@@ -0,0 +1,75 @@
+import { toast } from "sonner";
+import { create } from "zustand/react";
+import { getConfigPath, getSettings, saveSettings } from "@/client";
+import type { LauncherConfig } from "@/types";
+
+export interface SettingsState {
+ config: LauncherConfig | null;
+ configPath: string | null;
+
+ /* Theme getter */
+ get theme(): string;
+ /* Apply theme to the document */
+ applyTheme: (theme?: string) => void;
+
+ /* Refresh settings from the backend */
+ refresh: () => Promise<void>;
+ /* Save settings to the backend */
+ save: () => Promise<void>;
+ /* Update settings in the backend */
+ update: (config: LauncherConfig) => Promise<void>;
+ /* Merge settings with the current config without saving */
+ merge: (config: Partial<LauncherConfig>) => void;
+}
+
+export const useSettingsStore = create<SettingsState>((set, get) => ({
+ config: null,
+ configPath: null,
+
+ get theme() {
+ const { config } = get();
+ return config?.theme || "dark";
+ },
+ applyTheme: (theme?: string) => {
+ const { config } = get();
+ if (!config) return;
+ if (!theme) theme = config.theme;
+ let themeValue = theme;
+ if (theme === "system") {
+ themeValue = window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+ }
+ document.documentElement.classList.remove("light", "dark");
+ document.documentElement.setAttribute("data-theme", themeValue);
+ document.documentElement.classList.add(themeValue);
+ set({ config: { ...config, theme } });
+ },
+
+ refresh: async () => {
+ const { applyTheme } = get();
+ try {
+ const settings = await getSettings();
+ const path = await getConfigPath();
+ set({ config: settings, configPath: path });
+ applyTheme(settings.theme);
+ } catch (error) {
+ console.error("Failed to load settings:", error);
+ toast.error("Failed to load settings");
+ }
+ },
+ save: async () => {
+ const { config } = get();
+ if (!config) return;
+ await saveSettings(config);
+ },
+ update: async (config) => {
+ await saveSettings(config);
+ set({ config });
+ },
+ merge: (config) => {
+ const { config: currentConfig } = get();
+ if (!currentConfig) throw new Error("Settings not loaded");
+ set({ config: { ...currentConfig, ...config } });
+ },
+}));
diff --git a/packages/ui/src/pages/assistant-view.tsx.bk b/packages/ui/src/pages/assistant-view.tsx.bk
new file mode 100644
index 0000000..56f827b
--- /dev/null
+++ b/packages/ui/src/pages/assistant-view.tsx.bk
@@ -0,0 +1,485 @@
+import {
+ AlertTriangle,
+ Bot,
+ Brain,
+ ChevronDown,
+ Loader2,
+ RefreshCw,
+ Send,
+ Settings,
+ Trash2,
+} from "lucide-react";
+import { marked } from "marked";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Textarea } from "@/components/ui/textarea";
+import { toNumber } from "@/lib/tsrs-utils";
+import { type Message, useAssistantStore } from "../stores/assistant-store";
+import { useSettingsStore } from "../stores/settings-store";
+import { useUiStore } from "../stores/ui-store";
+
+interface ParsedMessage {
+ thinking: string | null;
+ content: string;
+ isThinking: boolean;
+}
+
+function parseMessageContent(content: string): ParsedMessage {
+ if (!content) return { thinking: null, content: "", isThinking: false };
+
+ // Support both <thinking> and <think> (DeepSeek uses <think>)
+ let startTag = "<thinking>";
+ let endTag = "</thinking>";
+ let startIndex = content.indexOf(startTag);
+
+ if (startIndex === -1) {
+ startTag = "<think>";
+ endTag = "</think>";
+ startIndex = content.indexOf(startTag);
+ }
+
+ // Also check for encoded tags if they weren't decoded properly
+ if (startIndex === -1) {
+ startTag = "\u003cthink\u003e";
+ endTag = "\u003c/think\u003e";
+ startIndex = content.indexOf(startTag);
+ }
+
+ if (startIndex !== -1) {
+ const endIndex = content.indexOf(endTag, startIndex);
+
+ if (endIndex !== -1) {
+ // Completed thinking block
+ const before = content.substring(0, startIndex);
+ const thinking = content
+ .substring(startIndex + startTag.length, endIndex)
+ .trim();
+ const after = content.substring(endIndex + endTag.length);
+
+ return {
+ thinking,
+ content: (before + after).trim(),
+ isThinking: false,
+ };
+ } else {
+ // Incomplete thinking block (still streaming)
+ const before = content.substring(0, startIndex);
+ const thinking = content.substring(startIndex + startTag.length).trim();
+
+ return {
+ thinking,
+ content: before.trim(),
+ isThinking: true,
+ };
+ }
+ }
+
+ return { thinking: null, content, isThinking: false };
+}
+
+function renderMarkdown(content: string): string {
+ if (!content) return "";
+ try {
+ return marked(content, { breaks: true, gfm: true }) as string;
+ } catch {
+ return content;
+ }
+}
+
+export function AssistantView() {
+ const {
+ messages,
+ isProcessing,
+ isProviderHealthy,
+ streamingContent,
+ init,
+ checkHealth,
+ sendMessage,
+ clearHistory,
+ } = useAssistantStore();
+ const { settings } = useSettingsStore();
+ const { setView } = useUiStore();
+
+ const [input, setInput] = useState("");
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+ const messagesContainerRef = useRef<HTMLDivElement>(null);
+
+ const provider = settings.assistant.llmProvider;
+ const endpoint =
+ provider === "ollama"
+ ? settings.assistant.ollamaEndpoint
+ : settings.assistant.openaiEndpoint;
+ const model =
+ provider === "ollama"
+ ? settings.assistant.ollamaModel
+ : settings.assistant.openaiModel;
+
+ const getProviderName = (): string => {
+ if (provider === "ollama") {
+ return `Ollama (${model})`;
+ } else if (provider === "openai") {
+ return `OpenAI (${model})`;
+ }
+ return provider;
+ };
+
+ const getProviderHelpText = (): string => {
+ if (provider === "ollama") {
+ return `Please ensure Ollama is installed and running at ${endpoint}.`;
+ } else if (provider === "openai") {
+ return "Please check your OpenAI API key in Settings > AI Assistant.";
+ }
+ return "";
+ };
+
+ const scrollToBottom = useCallback(() => {
+ if (messagesContainerRef.current) {
+ setTimeout(() => {
+ if (messagesContainerRef.current) {
+ messagesContainerRef.current.scrollTop =
+ messagesContainerRef.current.scrollHeight;
+ }
+ }, 0);
+ }
+ }, []);
+
+ useEffect(() => {
+ init();
+ }, [init]);
+
+ useEffect(() => {
+ if (messages.length > 0 || isProcessing) {
+ scrollToBottom();
+ }
+ }, [messages.length, isProcessing, scrollToBottom]);
+
+ const handleSubmit = async () => {
+ if (!input.trim() || isProcessing) return;
+ const text = input;
+ setInput("");
+ await sendMessage(text, settings.assistant.enabled, provider, endpoint);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ };
+
+ const renderMessage = (message: Message, index: number) => {
+ const isUser = message.role === "user";
+ const parsed = parseMessageContent(message.content);
+
+ return (
+ <div
+ key={index}
+ className={`flex ${isUser ? "justify-end" : "justify-start"} mb-4`}
+ >
+ <div
+ className={`max-w-[80%] rounded-2xl px-4 py-3 ${
+ isUser
+ ? "bg-indigo-500 text-white rounded-br-none"
+ : "bg-zinc-800 text-zinc-100 rounded-bl-none"
+ }`}
+ >
+ {!isUser && parsed.thinking && (
+ <div className="mb-3 max-w-full overflow-hidden">
+ <details className="group" open={parsed.isThinking}>
+ <summary className="list-none cursor-pointer flex items-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors text-xs font-medium select-none bg-black/20 p-2 rounded-lg border border-white/5 w-fit mb-2 outline-none">
+ <Brain className="h-3 w-3" />
+ <span>Thinking Process</span>
+ <ChevronDown className="h-3 w-3 transition-transform duration-200 group-open:rotate-180" />
+ </summary>
+ <div className="pl-3 border-l-2 border-zinc-700 text-zinc-500 text-xs italic leading-relaxed whitespace-pre-wrap font-mono max-h-96 overflow-y-auto custom-scrollbar bg-black/10 p-2 rounded-r-md">
+ {parsed.thinking}
+ {parsed.isThinking && (
+ <span className="inline-block w-1.5 h-3 bg-zinc-500 ml-1 animate-pulse align-middle" />
+ )}
+ </div>
+ </details>
+ </div>
+ )}
+ <div
+ className="prose prose-invert max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: renderMarkdown(parsed.content),
+ }}
+ />
+ {!isUser && message.stats && (
+ <div className="mt-2 pt-2 border-t border-zinc-700/50">
+ <div className="text-xs text-zinc-400">
+ {message.stats.evalCount} tokens ·{" "}
+ {Math.round(toNumber(message.stats.totalDuration) / 1000000)}
+ ms
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ };
+
+ return (
+ <div className="h-full w-full flex flex-col gap-4 p-4 lg:p-8">
+ <div className="flex items-center justify-between mb-2">
+ <div className="flex items-center gap-3">
+ <div className="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
+ <Bot size={24} />
+ </div>
+ <div>
+ <h2 className="text-2xl font-bold">Game Assistant</h2>
+ <p className="text-zinc-400 text-sm">
+ Powered by {getProviderName()}
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {!settings.assistant.enabled ? (
+ <Badge
+ variant="outline"
+ className="bg-zinc-500/10 text-zinc-400 border-zinc-500/20"
+ >
+ <AlertTriangle className="h-3 w-3 mr-1" />
+ Disabled
+ </Badge>
+ ) : !isProviderHealthy ? (
+ <Badge
+ variant="outline"
+ className="bg-red-500/10 text-red-400 border-red-500/20"
+ >
+ <AlertTriangle className="h-3 w-3 mr-1" />
+ Offline
+ </Badge>
+ ) : (
+ <Badge
+ variant="outline"
+ className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
+ >
+ <div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse mr-1" />
+ Online
+ </Badge>
+ )}
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={checkHealth}
+ title="Check Connection"
+ disabled={isProcessing}
+ >
+ <RefreshCw
+ className={`h-4 w-4 ${isProcessing ? "animate-spin" : ""}`}
+ />
+ </Button>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={clearHistory}
+ title="Clear History"
+ disabled={isProcessing}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => setView("settings")}
+ title="Settings"
+ >
+ <Settings className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+
+ {/* Chat Area */}
+ <div className="flex-1 bg-black/20 border border-white/5 rounded-xl overflow-hidden flex flex-col relative">
+ {/* Warning when assistant is disabled */}
+ {!settings.assistant.enabled && (
+ <div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10">
+ <Card className="bg-yellow-500/10 border-yellow-500/20">
+ <CardContent className="p-3 flex items-center gap-2">
+ <AlertTriangle className="h-4 w-4 text-yellow-500" />
+ <span className="text-yellow-500 text-sm font-medium">
+ Assistant is disabled. Enable it in Settings &gt; AI
+ Assistant.
+ </span>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+
+ {/* Provider offline warning */}
+ {settings.assistant.enabled && !isProviderHealthy && (
+ <div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10">
+ <Card className="bg-red-500/10 border-red-500/20">
+ <CardContent className="p-3 flex items-center gap-2">
+ <AlertTriangle className="h-4 w-4 text-red-500" />
+ <div className="flex flex-col">
+ <span className="text-red-500 text-sm font-medium">
+ Assistant is offline
+ </span>
+ <span className="text-red-400 text-xs">
+ {getProviderHelpText()}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+
+ {/* Messages Container */}
+ <ScrollArea className="flex-1 p-4 lg:p-6" ref={messagesContainerRef}>
+ {messages.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-full text-zinc-400 gap-4 mt-8">
+ <div className="p-4 bg-zinc-800/50 rounded-full">
+ <Bot className="h-12 w-12" />
+ </div>
+ <h3 className="text-xl font-medium">How can I help you today?</h3>
+ <p className="text-center max-w-md text-sm">
+ I can analyze your game logs, diagnose crashes, or explain mod
+ features.
+ {!settings.assistant.enabled && (
+ <span className="block mt-2 text-yellow-500">
+ Assistant is disabled. Enable it in{" "}
+ <button
+ type="button"
+ onClick={() => setView("settings")}
+ className="text-indigo-400 hover:underline"
+ >
+ Settings &gt; AI Assistant
+ </button>
+ .
+ </span>
+ )}
+ </p>
+ <div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-2 max-w-lg">
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput("How do I fix Minecraft crashing on launch?")
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ How do I fix Minecraft crashing on launch?
+ </div>
+ </Button>
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput("What's the best way to improve FPS?")
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ What's the best way to improve FPS?
+ </div>
+ </Button>
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput(
+ "Can you help me install Fabric for Minecraft 1.20.4?",
+ )
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ Can you help me install Fabric for 1.20.4?
+ </div>
+ </Button>
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput("What mods do you recommend for performance?")
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ What mods do you recommend for performance?
+ </div>
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <>
+ {messages.map((message, index) => renderMessage(message, index))}
+ {isProcessing && streamingContent && (
+ <div className="flex justify-start mb-4">
+ <div className="max-w-[80%] bg-zinc-800 text-zinc-100 rounded-2xl rounded-bl-none px-4 py-3">
+ <div
+ className="prose prose-invert max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: renderMarkdown(streamingContent),
+ }}
+ />
+ <div className="flex items-center gap-1 mt-2 text-xs text-zinc-400">
+ <Loader2 className="h-3 w-3 animate-spin" />
+ <span>Assistant is typing...</span>
+ </div>
+ </div>
+ </div>
+ )}
+ </>
+ )}
+ <div ref={messagesEndRef} />
+ </ScrollArea>
+
+ <Separator />
+
+ {/* Input Area */}
+ <div className="p-3 lg:p-4">
+ <div className="flex gap-2">
+ <Textarea
+ placeholder={
+ settings.assistant.enabled
+ ? "Ask about your game..."
+ : "Assistant is disabled. Enable it in Settings to use."
+ }
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ className="min-h-11 max-h-50 resize-none border-zinc-700 bg-zinc-900/50 focus:bg-zinc-900/80"
+ disabled={!settings.assistant.enabled || isProcessing}
+ />
+ <Button
+ onClick={handleSubmit}
+ disabled={
+ !settings.assistant.enabled || !input.trim() || isProcessing
+ }
+ className="px-6 bg-indigo-600 hover:bg-indigo-700 text-white"
+ >
+ {isProcessing ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Send className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
+ <div className="mt-2 flex items-center justify-between">
+ <div className="text-xs text-zinc-500">
+ {settings.assistant.enabled
+ ? "Press Enter to send, Shift+Enter for new line"
+ : "Enable the assistant in Settings to use"}
+ </div>
+ <div className="text-xs text-zinc-500">
+ Model: {model} • Provider: {provider}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/home-view.tsx b/packages/ui/src/pages/home-view.tsx
new file mode 100644
index 0000000..4f80cb0
--- /dev/null
+++ b/packages/ui/src/pages/home-view.tsx
@@ -0,0 +1,174 @@
+import { useEffect, useState } from "react";
+import { BottomBar } from "@/components/bottom-bar";
+import type { SaturnEffect } from "@/lib/effects/SaturnEffect";
+import { useGameStore } from "../stores/game-store";
+import { useReleasesStore } from "../stores/releases-store";
+
+export function HomeView() {
+ const gameStore = useGameStore();
+ const releasesStore = useReleasesStore();
+ const [mouseX, setMouseX] = useState(0);
+ const [mouseY, setMouseY] = useState(0);
+
+ useEffect(() => {
+ releasesStore.loadReleases();
+ }, [releasesStore.loadReleases]);
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ const x = (e.clientX / window.innerWidth) * 2 - 1;
+ const y = (e.clientY / window.innerHeight) * 2 - 1;
+ setMouseX(x);
+ setMouseY(y);
+
+ // Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions
+ try {
+ const saturn = (
+ window as unknown as {
+ getSaturnEffect?: () => SaturnEffect;
+ }
+ ).getSaturnEffect?.();
+ if (saturn?.handleMouseMove) {
+ saturn.handleMouseMove(e.clientX);
+ }
+ } catch {
+ /* best-effort, ignore errors from effect */
+ }
+ };
+
+ const handleSaturnMouseDown = (e: React.MouseEvent) => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseDown) {
+ saturn.handleMouseDown(e.clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnMouseUp = () => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseUp) {
+ saturn.handleMouseUp();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnMouseLeave = () => {
+ // Treat leaving the area as mouse-up for the effect
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseUp) {
+ saturn.handleMouseUp();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnTouchStart = (e: React.TouchEvent) => {
+ if (e.touches && e.touches.length === 1) {
+ try {
+ const clientX = e.touches[0].clientX;
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchStart) {
+ saturn.handleTouchStart(clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ };
+
+ const handleSaturnTouchMove = (e: React.TouchEvent) => {
+ if (e.touches && e.touches.length === 1) {
+ try {
+ const clientX = e.touches[0].clientX;
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchMove) {
+ saturn.handleTouchMove(clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ };
+
+ const handleSaturnTouchEnd = () => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchEnd) {
+ saturn.handleTouchEnd();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ return (
+ <div
+ className="relative z-10 h-full overflow-y-auto custom-scrollbar scroll-smooth"
+ style={{
+ overflow: releasesStore.isLoading ? "hidden" : "auto",
+ }}
+ >
+ {/* Hero Section (Full Height) - Interactive area */}
+ <div
+ role="tab"
+ className="min-h-full flex flex-col justify-end p-12 pb-32 cursor-grab active:cursor-grabbing select-none"
+ onMouseDown={handleSaturnMouseDown}
+ onMouseMove={handleMouseMove}
+ onMouseUp={handleSaturnMouseUp}
+ onMouseLeave={handleSaturnMouseLeave}
+ onTouchStart={handleSaturnTouchStart}
+ onTouchMove={handleSaturnTouchMove}
+ onTouchEnd={handleSaturnTouchEnd}
+ tabIndex={0}
+ >
+ {/* 3D Floating Hero Text */}
+ <div
+ className="transition-transform duration-200 ease-out origin-bottom-left"
+ style={{
+ transform: `perspective(1000px) rotateX(${mouseY * -1}deg) rotateY(${mouseX * 1}deg)`,
+ }}
+ >
+ <div className="flex items-center gap-3 mb-6">
+ <div className="h-px w-12 bg-white/50"></div>
+ <span className="text-xs font-mono font-bold tracking-[0.2em] text-white/50 uppercase">
+ Launcher Active
+ </span>
+ </div>
+
+ <h1 className="text-8xl font-black tracking-tighter text-white mb-6 leading-none">
+ MINECRAFT
+ </h1>
+
+ <div className="flex items-center gap-4">
+ <div className="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 className="h-4 w-px bg-white/20"></div>
+ <div className="text-sm text-zinc-400">
+ Latest Release{" "}
+ <span className="text-white font-medium">
+ {gameStore.latestRelease?.id || "..."}
+ </span>
+ </div>
+ </div>
+ </div>
+
+ {/* Action Area */}
+ <div className="mt-8 flex gap-4">
+ <div className="text-zinc-500 text-sm font-mono">
+ &gt; Ready to launch session.
+ </div>
+ </div>
+
+ <BottomBar />
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/index.tsx b/packages/ui/src/pages/index.tsx
new file mode 100644
index 0000000..54cfc1e
--- /dev/null
+++ b/packages/ui/src/pages/index.tsx
@@ -0,0 +1,76 @@
+import { useEffect } from "react";
+import { Outlet, useLocation } from "react-router";
+import { ParticleBackground } from "@/components/particle-background";
+import { Sidebar } from "@/components/sidebar";
+import { useAuthStore } from "@/models/auth";
+import { useSettingsStore } from "@/models/settings";
+
+export function IndexPage() {
+ const authStore = useAuthStore();
+ const settingsStore = useSettingsStore();
+
+ const location = useLocation();
+
+ useEffect(() => {
+ authStore.init();
+ settingsStore.refresh();
+ }, [authStore.init, settingsStore.refresh]);
+
+ return (
+ <div className="relative h-screen w-full overflow-hidden bg-background font-sans">
+ <div className="absolute inset-0 z-0 bg-gray-100 dark:bg-[#09090b] overflow-hidden">
+ {settingsStore.config?.customBackgroundPath && (
+ <>
+ <img
+ src={settingsStore.config?.customBackgroundPath}
+ alt="Background"
+ className="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear"
+ onError={(e) =>
+ console.error("Failed to load main background:", e)
+ }
+ />
+ {/* Dimming Overlay for readability */}
+ <div className="absolute inset-0 bg-black/50" />
+ </>
+ )}
+
+ {!settingsStore.config?.customBackgroundPath && (
+ <>
+ {settingsStore.theme === "dark" ? (
+ <div className="absolute inset-0 opacity-60 bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950"></div>
+ ) : (
+ <div className="absolute inset-0 opacity-100 bg-linear-to-br from-emerald-100 via-gray-100 to-indigo-100"></div>
+ )}
+
+ {location.pathname === "/" && <ParticleBackground />}
+
+ <div className="absolute inset-0 bg-linear-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50"></div>
+ </>
+ )}
+
+ {/* Subtle Grid Overlay */}
+ <div
+ className="absolute inset-0 z-0 dark:opacity-10 opacity-30 pointer-events-none"
+ style={{
+ backgroundImage: `linear-gradient(${
+ settingsStore.config?.theme === "dark" ? "#ffffff" : "#000000"
+ } 1px, transparent 1px), linear-gradient(90deg, ${
+ settingsStore.config?.theme === "dark" ? "#ffffff" : "#000000"
+ } 1px, transparent 1px)`,
+ backgroundSize: "40px 40px",
+ maskImage:
+ "radial-gradient(circle at 50% 50%, black 30%, transparent 70%)",
+ }}
+ />
+ </div>
+
+ <div className="size-full flex flex-row p-4 space-x-4 z-20 relative">
+ <Sidebar />
+
+ <main className="size-full overflow-hidden">
+ <Outlet />
+ </main>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/instances-view.tsx b/packages/ui/src/pages/instances-view.tsx
new file mode 100644
index 0000000..ad6bd38
--- /dev/null
+++ b/packages/ui/src/pages/instances-view.tsx
@@ -0,0 +1,315 @@
+import { Copy, Edit2, Plus, Trash2 } from "lucide-react";
+import { useEffect, useState } from "react";
+import InstanceCreationModal from "@/components/instance-creation-modal";
+import InstanceEditorModal from "@/components/instance-editor-modal";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { toNumber } from "@/lib/tsrs-utils";
+import { useInstancesStore } from "@/models/instances";
+import type { Instance } from "../types/bindings/instance";
+
+export function InstancesView() {
+ const instancesStore = useInstancesStore();
+
+ // Modal / UI state
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showDuplicateModal, setShowDuplicateModal] = useState(false);
+
+ // Selected / editing instance state
+ const [selectedInstance, setSelectedInstance] = useState<Instance | null>(
+ null,
+ );
+ const [editingInstance, setEditingInstance] = useState<Instance | null>(null);
+
+ // Form fields
+ const [duplicateName, setDuplicateName] = useState("");
+
+ useEffect(() => {
+ instancesStore.refresh();
+ }, [instancesStore.refresh]);
+
+ // Handlers to open modals
+ const openCreate = () => {
+ setShowCreateModal(true);
+ };
+
+ const openEdit = (instance: Instance) => {
+ setEditingInstance({ ...instance });
+ setShowEditModal(true);
+ };
+
+ const openDelete = (instance: Instance) => {
+ setSelectedInstance(instance);
+ setShowDeleteConfirm(true);
+ };
+
+ const openDuplicate = (instance: Instance) => {
+ setSelectedInstance(instance);
+ setDuplicateName(`${instance.name} (Copy)`);
+ setShowDuplicateModal(true);
+ };
+
+ const confirmDelete = async () => {
+ if (!selectedInstance) return;
+ await instancesStore.delete(selectedInstance.id);
+ setSelectedInstance(null);
+ setShowDeleteConfirm(false);
+ };
+
+ const confirmDuplicate = async () => {
+ if (!selectedInstance) return;
+ const name = duplicateName.trim();
+ if (!name) return;
+ await instancesStore.duplicate(selectedInstance.id, name);
+ setSelectedInstance(null);
+ setDuplicateName("");
+ setShowDuplicateModal(false);
+ };
+
+ const formatDate = (timestamp: number): string =>
+ new Date(timestamp * 1000).toLocaleDateString();
+
+ const formatLastPlayed = (timestamp: number): string => {
+ const date = new Date(timestamp * 1000);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) return "Today";
+ if (days === 1) return "Yesterday";
+ if (days < 7) return `${days} days ago`;
+ return date.toLocaleDateString();
+ };
+
+ return (
+ <div className="h-full flex flex-col gap-4 p-6 overflow-y-auto">
+ <div className="flex items-center justify-between">
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
+ Instances
+ </h1>
+ <Button
+ type="button"
+ onClick={openCreate}
+ className="px-4 py-2 transition-colors"
+ >
+ <Plus size={18} />
+ Create Instance
+ </Button>
+ </div>
+
+ {instancesStore.instances.length === 0 ? (
+ <div className="flex-1 flex items-center justify-center">
+ <div className="text-center text-gray-500 dark:text-gray-400">
+ <p className="text-lg mb-2">No instances yet</p>
+ <p className="text-sm">Create your first instance to get started</p>
+ </div>
+ </div>
+ ) : (
+ <ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ {instancesStore.instances.map((instance) => {
+ const isActive = instancesStore.activeInstance?.id === instance.id;
+
+ return (
+ <li
+ key={instance.id}
+ onClick={() => instancesStore.setActiveInstance(instance)}
+ onKeyDown={(e) =>
+ e.key === "Enter" &&
+ instancesStore.setActiveInstance(instance)
+ }
+ className={`relative p-4 text-left border-2 transition-all cursor-pointer hover:border-blue-500 ${
+ isActive ? "border-blue-500" : "border-transparent"
+ } bg-gray-100 dark:bg-gray-800`}
+ >
+ {/* Instance Icon */}
+ {instance.iconPath ? (
+ <div className="w-12 h-12 mb-3 rounded overflow-hidden">
+ <img
+ src={instance.iconPath}
+ alt={instance.name}
+ className="w-full h-full object-cover"
+ />
+ </div>
+ ) : (
+ <div className="w-12 h-12 mb-3 rounded bg-linear-to-br from-blue-500 to-purple-600 flex items-center justify-center">
+ <span className="text-white font-bold text-lg">
+ {instance.name.charAt(0).toUpperCase()}
+ </span>
+ </div>
+ )}
+
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
+ {instance.name}
+ </h3>
+
+ <div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
+ {instance.versionId ? (
+ <p className="truncate">Version: {instance.versionId}</p>
+ ) : (
+ <p className="text-gray-400">No version selected</p>
+ )}
+
+ {instance.modLoader && (
+ <p className="truncate">
+ Mod Loader:{" "}
+ <span className="capitalize">{instance.modLoader}</span>
+ </p>
+ )}
+
+ <p className="truncate">
+ Created: {formatDate(toNumber(instance.createdAt))}
+ </p>
+
+ {instance.lastPlayed && (
+ <p className="truncate">
+ Last played:{" "}
+ {formatLastPlayed(toNumber(instance.lastPlayed))}
+ </p>
+ )}
+ </div>
+
+ {/* Action Buttons */}
+ <div className="mt-4 flex gap-2">
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openEdit(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-sm transition-colors"
+ >
+ <Edit2 size={14} />
+ Edit
+ </button>
+
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openDuplicate(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-sm transition-colors"
+ >
+ <Copy size={14} />
+ Duplicate
+ </button>
+
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openDelete(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded text-sm transition-colors"
+ >
+ <Trash2 size={14} />
+ Delete
+ </button>
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+
+ <InstanceCreationModal
+ open={showCreateModal}
+ onOpenChange={setShowCreateModal}
+ />
+
+ <InstanceEditorModal
+ open={showEditModal}
+ instance={editingInstance}
+ onOpenChange={(open) => {
+ setShowEditModal(open);
+ if (!open) setEditingInstance(null);
+ }}
+ />
+
+ {/* Delete Confirmation */}
+ <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Instance</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete "{selectedInstance?.name}"? This
+ action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowDeleteConfirm(false);
+ setSelectedInstance(null);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmDelete}
+ className="bg-red-600 text-white hover:bg-red-500"
+ >
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Duplicate Modal */}
+ <Dialog open={showDuplicateModal} onOpenChange={setShowDuplicateModal}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Duplicate Instance</DialogTitle>
+ <DialogDescription>
+ Provide a name for the duplicated instance.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="mt-4">
+ <Input
+ value={duplicateName}
+ onChange={(e) => setDuplicateName(e.target.value)}
+ placeholder="New instance name"
+ onKeyDown={(e) => e.key === "Enter" && confirmDuplicate()}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowDuplicateModal(false);
+ setSelectedInstance(null);
+ setDuplicateName("");
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmDuplicate}
+ disabled={!duplicateName.trim()}
+ >
+ Duplicate
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/settings-view.tsx.bk b/packages/ui/src/pages/settings-view.tsx.bk
new file mode 100644
index 0000000..ac43d9b
--- /dev/null
+++ b/packages/ui/src/pages/settings-view.tsx.bk
@@ -0,0 +1,1158 @@
+import { open } from "@tauri-apps/plugin-dialog";
+import {
+ Coffee,
+ Download,
+ FileJson,
+ Loader2,
+ RefreshCw,
+ Upload,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Textarea } from "@/components/ui/textarea";
+import { useSettingsStore } from "../stores/settings-store";
+
+const effectOptions = [
+ { value: "saturn", label: "Saturn" },
+ { value: "constellation", label: "Network (Constellation)" },
+];
+
+const logServiceOptions = [
+ { value: "paste.rs", label: "paste.rs (Free, No Account)" },
+ { value: "pastebin.com", label: "pastebin.com (Requires API Key)" },
+];
+
+const llmProviderOptions = [
+ { value: "ollama", label: "Ollama (Local)" },
+ { value: "openai", label: "OpenAI (Remote)" },
+];
+
+const languageOptions = [
+ { value: "auto", label: "Auto (Match User)" },
+ { value: "English", label: "English" },
+ { value: "Chinese", label: "中文" },
+ { value: "Japanese", label: "日本語" },
+ { value: "Korean", label: "한국어" },
+ { value: "Spanish", label: "Español" },
+ { value: "French", label: "Français" },
+ { value: "German", label: "Deutsch" },
+ { value: "Russian", label: "Русский" },
+];
+
+const ttsProviderOptions = [
+ { value: "disabled", label: "Disabled" },
+ { value: "piper", label: "Piper TTS (Local)" },
+ { value: "edge", label: "Edge TTS (Online)" },
+];
+
+const personas = [
+ {
+ value: "default",
+ label: "Minecraft Expert (Default)",
+ prompt:
+ "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.",
+ },
+ {
+ value: "technical",
+ label: "Technical Debugger",
+ prompt:
+ "You are a technical support specialist for Minecraft. Focus strictly on analyzing logs, identifying crash causes, and providing technical solutions. Be precise and avoid conversational filler.",
+ },
+ {
+ value: "concise",
+ label: "Concise Helper",
+ prompt:
+ "You are a direct and concise assistant. Provide answers in as few words as possible while remaining accurate. Use bullet points for lists.",
+ },
+ {
+ value: "explain",
+ label: "Teacher / Explainer",
+ prompt:
+ "You are a patient teacher. Explain Minecraft concepts, redstone mechanics, and mod features in simple, easy-to-understand terms suitable for beginners.",
+ },
+ {
+ value: "pirate",
+ label: "Pirate Captain",
+ prompt:
+ "You are a salty Minecraft Pirate Captain! Yarr! Speak like a pirate while helping the crew (the user) with their blocky adventures. Use terms like 'matey', 'landlubber', and 'treasure'.",
+ },
+];
+
+export function SettingsView() {
+ const {
+ settings,
+ backgroundUrl,
+ javaInstallations,
+ isDetectingJava,
+ showJavaDownloadModal,
+ selectedDownloadSource,
+ javaCatalog,
+ isLoadingCatalog,
+ catalogError,
+ selectedMajorVersion,
+ selectedImageType,
+ showOnlyRecommended,
+ searchQuery,
+ isDownloadingJava,
+ downloadProgress,
+ javaDownloadStatus,
+ pendingDownloads,
+ ollamaModels,
+ openaiModels,
+ isLoadingOllamaModels,
+ isLoadingOpenaiModels,
+ ollamaModelsError,
+ openaiModelsError,
+ showConfigEditor,
+ rawConfigContent,
+ configFilePath,
+ configEditorError,
+ filteredReleases,
+ availableMajorVersions,
+ installStatus,
+ selectedRelease,
+ currentModelOptions,
+ loadSettings,
+ saveSettings,
+ detectJava,
+ selectJava,
+ openJavaDownloadModal,
+ closeJavaDownloadModal,
+ loadJavaCatalog,
+ refreshCatalog,
+ loadPendingDownloads,
+ selectMajorVersion,
+ downloadJava,
+ cancelDownload,
+ resumeDownloads,
+ openConfigEditor,
+ closeConfigEditor,
+ saveRawConfig,
+ loadOllamaModels,
+ loadOpenaiModels,
+ set,
+ setSetting,
+ setAssistantSetting,
+ setFeatureFlag,
+ } = useSettingsStore();
+
+ // Mark potentially-unused variables as referenced so TypeScript does not report
+ // them as unused in this file (they are part of the store API and used elsewhere).
+ // This is a no-op but satisfies the compiler.
+ void selectedDownloadSource;
+ void javaCatalog;
+ void javaDownloadStatus;
+ void pendingDownloads;
+ void ollamaModels;
+ void openaiModels;
+ void isLoadingOllamaModels;
+ void isLoadingOpenaiModels;
+ void ollamaModelsError;
+ void openaiModelsError;
+ void selectedRelease;
+ void loadJavaCatalog;
+ void loadPendingDownloads;
+ void cancelDownload;
+ void resumeDownloads;
+ void setFeatureFlag;
+ const [selectedPersona, setSelectedPersona] = useState("default");
+ const [migrating, setMigrating] = useState(false);
+ const [activeTab, setActiveTab] = useState("appearance");
+
+ useEffect(() => {
+ loadSettings();
+ detectJava();
+ }, [loadSettings, detectJava]);
+
+ useEffect(() => {
+ if (activeTab === "assistant") {
+ if (settings.assistant.llmProvider === "ollama") {
+ loadOllamaModels();
+ } else if (settings.assistant.llmProvider === "openai") {
+ loadOpenaiModels();
+ }
+ }
+ }, [
+ activeTab,
+ settings.assistant.llmProvider,
+ loadOllamaModels,
+ loadOpenaiModels,
+ ]);
+
+ const handleSelectBackground = async () => {
+ try {
+ const selected = await open({
+ multiple: false,
+ filters: [
+ {
+ name: "Images",
+ extensions: ["png", "jpg", "jpeg", "webp", "gif"],
+ },
+ ],
+ });
+
+ if (selected && typeof selected === "string") {
+ setSetting("customBackgroundPath", selected);
+ saveSettings();
+ }
+ } catch (e) {
+ console.error("Failed to select background:", e);
+ toast.error("Failed to select background");
+ }
+ };
+
+ const handleClearBackground = () => {
+ setSetting("customBackgroundPath", null);
+ saveSettings();
+ };
+
+ const handleApplyPersona = (value: string) => {
+ const persona = personas.find((p) => p.value === value);
+ if (persona) {
+ setAssistantSetting("systemPrompt", persona.prompt);
+ setSelectedPersona(value);
+ saveSettings();
+ }
+ };
+
+ const handleResetSystemPrompt = () => {
+ const defaultPersona = personas.find((p) => p.value === "default");
+ if (defaultPersona) {
+ setAssistantSetting("systemPrompt", defaultPersona.prompt);
+ setSelectedPersona("default");
+ saveSettings();
+ }
+ };
+
+ const handleRunMigration = async () => {
+ if (migrating) return;
+ setMigrating(true);
+ try {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ toast.success("Migration complete! Files migrated successfully");
+ } catch (e) {
+ console.error("Migration failed:", e);
+ toast.error(`Migration failed: ${e}`);
+ } finally {
+ setMigrating(false);
+ }
+ };
+
+ return (
+ <div className="h-full flex flex-col p-6 overflow-hidden">
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">
+ Settings
+ </h2>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={openConfigEditor}
+ className="gap-2"
+ >
+ <FileJson className="h-4 w-4" />
+ <span className="hidden sm:inline">Open JSON</span>
+ </Button>
+ </div>
+
+ <Tabs
+ value={activeTab}
+ onValueChange={setActiveTab}
+ className="flex-1 overflow-hidden"
+ >
+ <TabsList className="grid grid-cols-4 mb-6">
+ <TabsTrigger value="appearance">Appearance</TabsTrigger>
+ <TabsTrigger value="java">Java</TabsTrigger>
+ <TabsTrigger value="advanced">Advanced</TabsTrigger>
+ <TabsTrigger value="assistant">Assistant</TabsTrigger>
+ </TabsList>
+
+ <ScrollArea className="flex-1 pr-2">
+ <TabsContent value="appearance" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Appearance</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-3">Custom Background Image</Label>
+ <div className="flex items-center gap-6">
+ <div className="w-40 h-24 rounded-xl overflow-hidden bg-secondary border relative group shadow-lg">
+ {backgroundUrl ? (
+ <img
+ src={backgroundUrl}
+ alt="Background Preview"
+ className="w-full h-full object-cover"
+ onError={(e) => {
+ console.error("Failed to load image");
+ e.currentTarget.style.display = "none";
+ }}
+ />
+ ) : (
+ <div className="w-full h-full bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950" />
+ )}
+ {!backgroundUrl && (
+ <div className="absolute inset-0 flex items-center justify-center text-xs text-white/50 bg-black/20">
+ Default Gradient
+ </div>
+ )}
+ </div>
+
+ <div className="flex flex-col gap-2">
+ <Button
+ variant="outline"
+ onClick={handleSelectBackground}
+ >
+ Select Image
+ </Button>
+ {backgroundUrl && (
+ <Button
+ variant="ghost"
+ className="text-red-500"
+ onClick={handleClearBackground}
+ >
+ Reset to Default
+ </Button>
+ )}
+ </div>
+ </div>
+ <p className="text-sm text-muted-foreground mt-3">
+ Select an image from your computer to replace the default
+ gradient background.
+ </p>
+ </div>
+
+ <Separator />
+
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Visual Effects</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable particle effects and animated gradients.
+ </p>
+ </div>
+ <Switch
+ checked={settings.enableVisualEffects}
+ onCheckedChange={(checked) => {
+ setSetting("enableVisualEffects", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {settings.enableVisualEffects && (
+ <div className="pl-4 border-l-2 border-border">
+ <div className="space-y-2">
+ <Label>Theme Effect</Label>
+ <Select
+ value={settings.activeEffect}
+ onValueChange={(value) => {
+ setSetting("activeEffect", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger className="w-52">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {effectOptions.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <p className="text-sm text-muted-foreground">
+ Select the active visual theme.
+ </p>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">GPU Acceleration</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable GPU acceleration for the interface.
+ </p>
+ </div>
+ <Switch
+ checked={settings.enableGpuAcceleration}
+ onCheckedChange={(checked) => {
+ setSetting("enableGpuAcceleration", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="java" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Java Environment</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-2">Java Path</Label>
+ <div className="flex gap-2">
+ <Input
+ value={settings.javaPath}
+ onChange={(e) => setSetting("javaPath", e.target.value)}
+ className="flex-1"
+ placeholder="java or full path to java executable"
+ />
+ <Button
+ variant="outline"
+ onClick={() => detectJava()}
+ disabled={isDetectingJava}
+ >
+ {isDetectingJava ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ "Detect"
+ )}
+ </Button>
+ </div>
+ <p className="text-sm text-muted-foreground mt-2">
+ Path to Java executable.
+ </p>
+ </div>
+
+ <div>
+ <Label className="mb-2">Memory Settings (MB)</Label>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label htmlFor="min-memory" className="text-sm">
+ Minimum Memory
+ </Label>
+ <Input
+ id="min-memory"
+ type="number"
+ value={settings.minMemory}
+ onChange={(e) =>
+ setSetting(
+ "minMemory",
+ parseInt(e.target.value, 10) || 1024,
+ )
+ }
+ min={512}
+ step={256}
+ />
+ </div>
+ <div>
+ <Label htmlFor="max-memory" className="text-sm">
+ Maximum Memory
+ </Label>
+ <Input
+ id="max-memory"
+ type="number"
+ value={settings.maxMemory}
+ onChange={(e) =>
+ setSetting(
+ "maxMemory",
+ parseInt(e.target.value, 10) || 2048,
+ )
+ }
+ min={1024}
+ step={256}
+ />
+ </div>
+ </div>
+ <p className="text-sm text-muted-foreground mt-2">
+ Memory allocation for Minecraft.
+ </p>
+ </div>
+
+ <Separator />
+
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <Label className="text-base">
+ Detected Java Installations
+ </Label>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => detectJava()}
+ disabled={isDetectingJava}
+ >
+ <RefreshCw
+ className={`h-4 w-4 mr-2 ${isDetectingJava ? "animate-spin" : ""}`}
+ />
+ Rescan
+ </Button>
+ </div>
+
+ {javaInstallations.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground border rounded-lg">
+ <Coffee className="h-12 w-12 mx-auto mb-4 opacity-30" />
+ <p>No Java installations detected</p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {javaInstallations.map((installation) => (
+ <Card
+ key={installation.path}
+ className={`p-3 cursor-pointer transition-colors ${
+ settings.javaPath === installation.path
+ ? "border-primary bg-primary/5"
+ : ""
+ }`}
+ onClick={() => selectJava(installation.path)}
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-medium flex items-center gap-2">
+ <Coffee className="h-4 w-4" />
+ {installation.version}
+ </div>
+ <div className="text-sm text-muted-foreground font-mono">
+ {installation.path}
+ </div>
+ </div>
+ {settings.javaPath === installation.path && (
+ <div className="h-5 w-5 text-primary">✓</div>
+ )}
+ </div>
+ </Card>
+ ))}
+ </div>
+ )}
+
+ <div className="mt-4">
+ <Button
+ variant="default"
+ className="w-full"
+ onClick={openJavaDownloadModal}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ Download Java
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="advanced" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Advanced Settings</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-2">Download Threads</Label>
+ <Input
+ type="number"
+ value={settings.downloadThreads}
+ onChange={(e) =>
+ setSetting(
+ "downloadThreads",
+ parseInt(e.target.value, 10) || 32,
+ )
+ }
+ min={1}
+ max={64}
+ />
+ <p className="text-sm text-muted-foreground mt-2">
+ Number of concurrent downloads.
+ </p>
+ </div>
+
+ <div>
+ <Label className="mb-2">Log Upload Service</Label>
+ <Select
+ value={settings.logUploadService}
+ onValueChange={(value) => {
+ setSetting("logUploadService", value as any);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {logServiceOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {settings.logUploadService === "pastebin.com" && (
+ <div>
+ <Label className="mb-2">Pastebin API Key</Label>
+ <Input
+ type="password"
+ value={settings.pastebinApiKey || ""}
+ onChange={(e) =>
+ setSetting("pastebinApiKey", e.target.value || null)
+ }
+ placeholder="Enter your Pastebin API key"
+ />
+ </div>
+ )}
+
+ <Separator />
+
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Use Shared Caches</Label>
+ <p className="text-sm text-muted-foreground">
+ Share downloaded assets between instances.
+ </p>
+ </div>
+ <Switch
+ checked={settings.useSharedCaches}
+ onCheckedChange={(checked) => {
+ setSetting("useSharedCaches", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {!settings.useSharedCaches && (
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">
+ Keep Legacy Per-Instance Storage
+ </Label>
+ <p className="text-sm text-muted-foreground">
+ Maintain separate cache folders for compatibility.
+ </p>
+ </div>
+ <Switch
+ checked={settings.keepLegacyPerInstanceStorage}
+ onCheckedChange={(checked) => {
+ setSetting("keepLegacyPerInstanceStorage", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+ )}
+
+ {settings.useSharedCaches && (
+ <div className="mt-4">
+ <Button
+ variant="outline"
+ className="w-full"
+ onClick={handleRunMigration}
+ disabled={migrating}
+ >
+ {migrating ? (
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ ) : (
+ <Upload className="h-4 w-4 mr-2" />
+ )}
+ {migrating
+ ? "Migrating..."
+ : "Migrate to Shared Caches"}
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="assistant" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">AI Assistant</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Enable Assistant</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable the AI assistant for help with Minecraft issues.
+ </p>
+ </div>
+ <Switch
+ checked={settings.assistant.enabled}
+ onCheckedChange={(checked) => {
+ setAssistantSetting("enabled", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {settings.assistant.enabled && (
+ <>
+ <div>
+ <Label className="mb-2">LLM Provider</Label>
+ <Select
+ value={settings.assistant.llmProvider}
+ onValueChange={(value) => {
+ setAssistantSetting("llmProvider", value as any);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {llmProviderOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Model</Label>
+ <Select
+ value={
+ settings.assistant.llmProvider === "ollama"
+ ? settings.assistant.ollamaModel
+ : settings.assistant.openaiModel
+ }
+ onValueChange={(value) => {
+ if (settings.assistant.llmProvider === "ollama") {
+ setAssistantSetting("ollamaModel", value);
+ } else {
+ setAssistantSetting("openaiModel", value);
+ }
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {currentModelOptions.map((model) => (
+ <SelectItem key={model.value} value={model.value}>
+ {model.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {settings.assistant.llmProvider === "ollama" && (
+ <div>
+ <Label className="mb-2">Ollama Endpoint</Label>
+ <Input
+ value={settings.assistant.ollamaEndpoint}
+ onChange={(e) => {
+ setAssistantSetting(
+ "ollamaEndpoint",
+ e.target.value,
+ );
+ saveSettings();
+ }}
+ placeholder="http://localhost:11434"
+ />
+ </div>
+ )}
+
+ {settings.assistant.llmProvider === "openai" && (
+ <>
+ <div>
+ <Label className="mb-2">OpenAI API Key</Label>
+ <Input
+ type="password"
+ value={settings.assistant.openaiApiKey || ""}
+ onChange={(e) => {
+ setAssistantSetting(
+ "openaiApiKey",
+ e.target.value || null,
+ );
+ saveSettings();
+ }}
+ placeholder="Enter your OpenAI API key"
+ />
+ </div>
+ <div>
+ <Label className="mb-2">OpenAI Endpoint</Label>
+ <Input
+ value={settings.assistant.openaiEndpoint}
+ onChange={(e) => {
+ setAssistantSetting(
+ "openaiEndpoint",
+ e.target.value,
+ );
+ saveSettings();
+ }}
+ placeholder="https://api.openai.com/v1"
+ />
+ </div>
+ </>
+ )}
+
+ <div>
+ <Label className="mb-2">Response Language</Label>
+ <Select
+ value={settings.assistant.responseLanguage}
+ onValueChange={(value) => {
+ setAssistantSetting("responseLanguage", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {languageOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Assistant Persona</Label>
+ <Select
+ value={selectedPersona}
+ onValueChange={handleApplyPersona}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {personas.map((persona) => (
+ <SelectItem
+ key={persona.value}
+ value={persona.value}
+ >
+ {persona.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <div className="mt-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleResetSystemPrompt}
+ >
+ Reset to Default
+ </Button>
+ </div>
+ </div>
+
+ <div>
+ <Label className="mb-2">System Prompt</Label>
+
+ <Textarea
+ value={settings.assistant.systemPrompt}
+ onChange={(e) => {
+ setAssistantSetting("systemPrompt", e.target.value);
+ saveSettings();
+ }}
+ rows={6}
+ className="font-mono text-sm"
+ />
+ </div>
+
+ <div>
+ <Label className="mb-2">Text-to-Speech</Label>
+
+ <Select
+ value={settings.assistant.ttsProvider}
+ onValueChange={(value) => {
+ setAssistantSetting("ttsProvider", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+
+ <SelectContent>
+ {ttsProviderOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </ScrollArea>
+ </Tabs>
+
+ {/* Java Download Modal */}
+ <Dialog
+ open={showJavaDownloadModal}
+ onOpenChange={closeJavaDownloadModal}
+ >
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Download Java</DialogTitle>
+ <DialogDescription>
+ Download and install Java for Minecraft.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-4">
+ <div>
+ <Label className="mb-2">Java Version</Label>
+ <Select
+ value={selectedMajorVersion?.toString() || ""}
+ onValueChange={(v) => selectMajorVersion(parseInt(v, 10))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select version" />
+ </SelectTrigger>
+ <SelectContent>
+ {availableMajorVersions.map((version) => (
+ <SelectItem key={version} value={version.toString()}>
+ Java {version}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Type</Label>
+ <Select
+ value={selectedImageType}
+ onValueChange={(v) => set({ selectedImageType: v as any })}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="jre">JRE (Runtime)</SelectItem>
+ <SelectItem value="jdk">JDK (Development)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="recommended"
+ checked={showOnlyRecommended}
+ onCheckedChange={(checked) =>
+ set({ showOnlyRecommended: !!checked })
+ }
+ />
+ <Label htmlFor="recommended">Show only LTS/Recommended</Label>
+ </div>
+
+ <div>
+ <Label className="mb-2">Search</Label>
+ <Input
+ placeholder="Search versions..."
+ value={searchQuery}
+ onChange={(e) => set({ searchQuery: e.target.value })}
+ />
+ </div>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={refreshCatalog}
+ disabled={isLoadingCatalog}
+ >
+ <RefreshCw
+ className={`h-4 w-4 mr-2 ${isLoadingCatalog ? "animate-spin" : ""}`}
+ />
+ Refresh Catalog
+ </Button>
+ </div>
+
+ <div className="md:col-span-2">
+ <ScrollArea className="h-75 pr-4">
+ {isLoadingCatalog ? (
+ <div className="flex items-center justify-center h-full">
+ <Loader2 className="h-8 w-8 animate-spin" />
+ </div>
+ ) : catalogError ? (
+ <div className="text-red-500 p-4">{catalogError}</div>
+ ) : filteredReleases.length === 0 ? (
+ <div className="text-muted-foreground p-4 text-center">
+ No Java versions found
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredReleases.map((release) => {
+ const status = installStatus(
+ release.majorVersion,
+ release.imageType,
+ );
+ return (
+ <Card
+ key={`${release.majorVersion}-${release.imageType}`}
+ className="p-3 cursor-pointer hover:bg-accent"
+ onClick={() =>
+ selectMajorVersion(release.majorVersion)
+ }
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-medium">
+ Java {release.majorVersion}{" "}
+ {release.imageType.toUpperCase()}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {release.releaseName} • {release.architecture}{" "}
+ {release.architecture}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {release.isLts && (
+ <Badge variant="secondary">LTS</Badge>
+ )}
+ {status === "installed" && (
+ <Badge variant="default">Installed</Badge>
+ )}
+ {status === "available" && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation();
+ selectMajorVersion(release.majorVersion);
+ downloadJava();
+ }}
+ >
+ <Download className="h-3 w-3 mr-1" />
+ Download
+ </Button>
+ )}
+ </div>
+ </div>
+ </Card>
+ );
+ })}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+ </div>
+
+ {isDownloadingJava && downloadProgress && (
+ <div className="mt-4 p-4 border rounded-lg">
+ <div className="flex justify-between items-center mb-2">
+ <span className="text-sm font-medium">
+ {downloadProgress.fileName}
+ </span>
+ <span className="text-sm text-muted-foreground">
+ {Math.round(downloadProgress.percentage)}%
+ </span>
+ </div>
+ <div className="w-full bg-secondary h-2 rounded-full overflow-hidden">
+ <div
+ className="bg-primary h-full transition-all duration-300"
+ style={{ width: `${downloadProgress.percentage}%` }}
+ />
+ </div>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={closeJavaDownloadModal}
+ disabled={isDownloadingJava}
+ >
+ Cancel
+ </Button>
+ {selectedMajorVersion && (
+ <Button
+ onClick={() => downloadJava()}
+ disabled={isDownloadingJava}
+ >
+ {isDownloadingJava ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Downloading...
+ </>
+ ) : (
+ <>
+ <Download className="mr-2 h-4 w-4" />
+ Download Java {selectedMajorVersion}
+ </>
+ )}
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Config Editor Modal */}
+ <Dialog open={showConfigEditor} onOpenChange={closeConfigEditor}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Edit Configuration</DialogTitle>
+ <DialogDescription>
+ Edit the raw JSON configuration file.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="text-sm text-muted-foreground mb-2">
+ File: {configFilePath}
+ </div>
+
+ {configEditorError && (
+ <div className="text-red-500 p-3 bg-red-50 dark:bg-red-950/30 rounded-md">
+ {configEditorError}
+ </div>
+ )}
+
+ <Textarea
+ value={rawConfigContent}
+ onChange={(e) => set({ rawConfigContent: e.target.value })}
+ className="font-mono text-sm h-100 resize-none"
+ spellCheck={false}
+ />
+
+ <DialogFooter>
+ <Button variant="outline" onClick={closeConfigEditor}>
+ Cancel
+ </Button>
+ <Button onClick={() => saveRawConfig()}>Save Changes</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/settings.tsx b/packages/ui/src/pages/settings.tsx
new file mode 100644
index 0000000..440a5dc
--- /dev/null
+++ b/packages/ui/src/pages/settings.tsx
@@ -0,0 +1,310 @@
+import { toNumber } from "es-toolkit/compat";
+import { FileJsonIcon } from "lucide-react";
+import { useEffect, useState } from "react";
+import { migrateSharedCaches } from "@/client";
+import { ConfigEditor } from "@/components/config-editor";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSet,
+} from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useSettingsStore } from "@/models/settings";
+
+export type SettingsTab = "general" | "appearance" | "advanced";
+
+export function SettingsPage() {
+ const { config, ...settings } = useSettingsStore();
+ const [showConfigEditor, setShowConfigEditor] = useState<boolean>(false);
+ const [activeTab, setActiveTab] = useState<SettingsTab>("general");
+
+ useEffect(() => {
+ if (!config) settings.refresh();
+ }, [config, settings.refresh]);
+
+ const renderScrollArea = () => {
+ if (!config) {
+ return (
+ <div className="size-full justify-center items-center">
+ <Spinner />
+ </div>
+ );
+ }
+ return (
+ <ScrollArea className="size-full pr-2">
+ <TabsContent value="general" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">General</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FieldGroup>
+ <FieldSet>
+ <FieldLegend>Window Options</FieldLegend>
+ <FieldDescription>
+ May not work on some platforms like Linux Niri.
+ </FieldDescription>
+ <FieldGroup>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <Field>
+ <FieldLabel htmlFor="width">
+ Window Default Width
+ </FieldLabel>
+ <Input
+ type="number"
+ name="width"
+ value={config?.width}
+ onChange={(e) => {
+ settings.merge({
+ width: toNumber(e.target.value),
+ });
+ }}
+ onBlur={() => {
+ settings.save();
+ }}
+ min={800}
+ max={3840}
+ />
+ </Field>
+ <Field>
+ <FieldLabel htmlFor="height">
+ Window Default Height
+ </FieldLabel>
+ <Input
+ type="number"
+ name="height"
+ value={config?.height}
+ onChange={(e) => {
+ settings.merge({
+ height: toNumber(e.target.value),
+ });
+ }}
+ onBlur={() => {
+ settings.save();
+ }}
+ min={600}
+ max={2160}
+ />
+ </Field>
+ </div>
+ <Field className="flex flex-row items-center justify-between">
+ <FieldContent>
+ <FieldLabel htmlFor="gpu-acceleration">
+ GPU Acceleration
+ </FieldLabel>
+ <FieldDescription>
+ Enable GPU acceleration for the interface.
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ checked={config?.enableGpuAcceleration}
+ onCheckedChange={(checked) => {
+ settings.merge({
+ enableGpuAcceleration: checked,
+ });
+ settings.save();
+ }}
+ />
+ </Field>
+ </FieldGroup>
+ </FieldSet>
+ <FieldSet>
+ <FieldLegend>Network Options</FieldLegend>
+ <Field>
+ <Label htmlFor="download-threads">Download Threads</Label>
+ <Input
+ type="number"
+ name="download-threads"
+ value={config?.downloadThreads}
+ onChange={(e) => {
+ settings.merge({
+ downloadThreads: toNumber(e.target.value),
+ });
+ }}
+ onBlur={() => {
+ settings.save();
+ }}
+ min={1}
+ max={64}
+ />
+ </Field>
+ </FieldSet>
+ </FieldGroup>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ <TabsContent value="java" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">
+ Java Installations
+ </CardTitle>
+ <CardContent></CardContent>
+ </CardHeader>
+ </Card>
+ </TabsContent>
+ <TabsContent value="appearance" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">Appearance</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FieldGroup>
+ <Field className="flex flex-row">
+ <FieldContent>
+ <FieldLabel htmlFor="theme">Theme</FieldLabel>
+ <FieldDescription>
+ Select your prefered theme.
+ </FieldDescription>
+ </FieldContent>
+ <Select
+ items={[
+ { label: "Dark", value: "dark" },
+ { label: "Light", value: "light" },
+ { label: "System", value: "system" },
+ ]}
+ value={config.theme}
+ onValueChange={async (value) => {
+ if (
+ value === "system" ||
+ value === "light" ||
+ value === "dark"
+ ) {
+ settings.merge({ theme: value });
+ await settings.save();
+ settings.applyTheme(value);
+ }
+ }}
+ >
+ <SelectTrigger className="w-full max-w-48">
+ <SelectValue placeholder="Please select a prefered theme" />
+ </SelectTrigger>
+ <SelectContent alignItemWithTrigger={false}>
+ <SelectGroup>
+ <SelectItem value="system">System</SelectItem>
+ <SelectItem value="light">Light</SelectItem>
+ <SelectItem value="dark">Dark</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </Field>
+ </FieldGroup>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ <TabsContent value="advanced" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">Advanced</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FieldGroup>
+ <FieldSet>
+ <FieldLegend>Advanced Options</FieldLegend>
+ <FieldGroup>
+ <Field className="flex flex-row items-center justify-between">
+ <FieldContent>
+ <FieldLabel htmlFor="use-shared-caches">
+ Use Shared Caches
+ </FieldLabel>
+ <FieldDescription>
+ Share downloaded assets between instances.
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ checked={config?.useSharedCaches}
+ onCheckedChange={async (checked) => {
+ checked && (await migrateSharedCaches());
+ settings.merge({
+ useSharedCaches: checked,
+ });
+ settings.save();
+ }}
+ />
+ </Field>
+ <Field className="flex flex-row items-center justify-between">
+ <FieldContent>
+ <FieldLabel htmlFor="keep-per-instance-storage">
+ Keep Legacy Per-Instance Storage
+ </FieldLabel>
+ <FieldDescription>
+ Maintain separate cache folders for compatibility.
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ checked={config?.keepLegacyPerInstanceStorage}
+ onCheckedChange={(checked) => {
+ settings.merge({
+ keepLegacyPerInstanceStorage: checked,
+ });
+ settings.save();
+ }}
+ />
+ </Field>
+ </FieldGroup>
+ </FieldSet>
+ </FieldGroup>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </ScrollArea>
+ );
+ };
+
+ return (
+ <div className="size-full flex flex-col p-6 space-y-6">
+ <div className="flex items-center justify-between">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">
+ Settings
+ </h2>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowConfigEditor(true)}
+ >
+ <FileJsonIcon />
+ <span className="hidden sm:inline">Open JSON</span>
+ </Button>
+ </div>
+
+ <Tabs
+ value={activeTab}
+ onValueChange={setActiveTab}
+ className="size-full flex flex-col gap-6"
+ >
+ <TabsList>
+ <TabsTrigger value="general">General</TabsTrigger>
+ <TabsTrigger value="java">Java</TabsTrigger>
+ <TabsTrigger value="appearance">Appearance</TabsTrigger>
+ <TabsTrigger value="advanced">Advanced</TabsTrigger>
+ </TabsList>
+ {renderScrollArea()}
+ </Tabs>
+
+ <ConfigEditor
+ open={showConfigEditor}
+ onOpenChange={() => setShowConfigEditor(false)}
+ />
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/versions-view.tsx.bk b/packages/ui/src/pages/versions-view.tsx.bk
new file mode 100644
index 0000000..d54596d
--- /dev/null
+++ b/packages/ui/src/pages/versions-view.tsx.bk
@@ -0,0 +1,662 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { Coffee, Loader2, Search, Trash2 } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useInstancesStore } from "../models/instances";
+import { useGameStore } from "../stores/game-store";
+import type { Version } from "../types/bindings/manifest";
+
+interface InstalledModdedVersion {
+ id: string;
+ javaVersion?: number;
+}
+
+type TypeFilter = "all" | "release" | "snapshot" | "installed";
+
+export function VersionsView() {
+ const { versions, selectedVersion, loadVersions, setSelectedVersion } =
+ useGameStore();
+ const { activeInstance } = useInstancesStore();
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
+ const [installedModdedVersions, setInstalledModdedVersions] = useState<
+ InstalledModdedVersion[]
+ >([]);
+ const [, setIsLoadingModded] = useState(false);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [selectedVersionMetadata, setSelectedVersionMetadata] = useState<{
+ id: string;
+ javaVersion?: number;
+ isInstalled: boolean;
+ } | null>(null);
+ const [isLoadingMetadata, setIsLoadingMetadata] = useState(false);
+ const [showModLoaderSelector, setShowModLoaderSelector] = useState(false);
+
+ const normalizedQuery = searchQuery.trim().toLowerCase().replace(/。/g, ".");
+
+ // Load installed modded versions with Java version info
+ const loadInstalledModdedVersions = useCallback(async () => {
+ if (!activeInstance) {
+ setInstalledModdedVersions([]);
+ setIsLoadingModded(false);
+ return;
+ }
+
+ setIsLoadingModded(true);
+ try {
+ const allInstalled = await invoke<Array<{ id: string; type: string }>>(
+ "list_installed_versions",
+ { instanceId: activeInstanceId },
+ );
+
+ const moddedIds = allInstalled
+ .filter((v) => v.type === "fabric" || v.type === "forge")
+ .map((v) => v.id);
+
+ const versionsWithJava = await Promise.all(
+ moddedIds.map(async (id) => {
+ try {
+ const javaVersion = await invoke<number | null>(
+ "get_version_java_version",
+ {
+ instanceId: activeInstanceId,
+ versionId: id,
+ },
+ );
+ return {
+ id,
+ javaVersion: javaVersion ?? undefined,
+ };
+ } catch (e) {
+ console.error(`Failed to get Java version for ${id}:`, e);
+ return { id, javaVersion: undefined };
+ }
+ }),
+ );
+
+ setInstalledModdedVersions(versionsWithJava);
+ } catch (e) {
+ console.error("Failed to load installed modded versions:", e);
+ toast.error("Error loading modded versions");
+ } finally {
+ setIsLoadingModded(false);
+ }
+ }, [activeInstanceId]);
+
+ // Combined versions list (vanilla + modded)
+ const allVersions = (() => {
+ const moddedVersions: Version[] = installedModdedVersions.map((v) => {
+ const versionType = v.id.startsWith("fabric-loader-")
+ ? "fabric"
+ : v.id.includes("-forge-")
+ ? "forge"
+ : "fabric";
+ return {
+ id: v.id,
+ type: versionType,
+ url: "",
+ time: "",
+ releaseTime: new Date().toISOString(),
+ javaVersion: BigInt(v.javaVersion ?? 0),
+ isInstalled: true,
+ };
+ });
+ return [...moddedVersions, ...versions];
+ })();
+
+ // Filter versions based on search and type filter
+ const filteredVersions = allVersions.filter((version) => {
+ if (typeFilter === "release" && version.type !== "release") return false;
+ if (typeFilter === "snapshot" && version.type !== "snapshot") return false;
+ if (typeFilter === "installed" && !version.isInstalled) return false;
+
+ if (
+ normalizedQuery &&
+ !version.id.toLowerCase().includes(normalizedQuery)
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+
+ // Get version badge styling
+ const getVersionBadge = (type: string) => {
+ switch (type) {
+ case "release":
+ return {
+ text: "Release",
+ variant: "default" as const,
+ className: "bg-emerald-500 hover:bg-emerald-600",
+ };
+ case "snapshot":
+ return {
+ text: "Snapshot",
+ variant: "secondary" as const,
+ className: "bg-amber-500 hover:bg-amber-600",
+ };
+ case "fabric":
+ return {
+ text: "Fabric",
+ variant: "outline" as const,
+ className: "border-indigo-500 text-indigo-700 dark:text-indigo-300",
+ };
+ case "forge":
+ return {
+ text: "Forge",
+ variant: "outline" as const,
+ className: "border-orange-500 text-orange-700 dark:text-orange-300",
+ };
+ case "modpack":
+ return {
+ text: "Modpack",
+ variant: "outline" as const,
+ className: "border-purple-500 text-purple-700 dark:text-purple-300",
+ };
+ default:
+ return {
+ text: type,
+ variant: "outline" as const,
+ className: "border-gray-500 text-gray-700 dark:text-gray-300",
+ };
+ }
+ };
+
+ // Load version metadata
+ const loadVersionMetadata = useCallback(
+ async (versionId: string) => {
+ if (!versionId || !activeInstanceId) {
+ setSelectedVersionMetadata(null);
+ return;
+ }
+
+ setIsLoadingMetadata(true);
+ try {
+ const metadata = await invoke<{
+ id: string;
+ javaVersion?: number;
+ isInstalled: boolean;
+ }>("get_version_metadata", {
+ instanceId: activeInstanceId,
+ versionId,
+ });
+ setSelectedVersionMetadata(metadata);
+ } catch (e) {
+ console.error("Failed to load version metadata:", e);
+ setSelectedVersionMetadata(null);
+ } finally {
+ setIsLoadingMetadata(false);
+ }
+ },
+ [activeInstanceId],
+ );
+
+ // Get base version for mod loader selector
+ const selectedBaseVersion = (() => {
+ if (!selectedVersion) return "";
+
+ if (selectedVersion.startsWith("fabric-loader-")) {
+ const parts = selectedVersion.split("-");
+ return parts[parts.length - 1];
+ }
+ if (selectedVersion.includes("-forge-")) {
+ return selectedVersion.split("-forge-")[0];
+ }
+
+ const version = versions.find((v) => v.id === selectedVersion);
+ return version ? selectedVersion : "";
+ })();
+
+ // Handle version deletion
+ const handleDeleteVersion = async () => {
+ if (!versionToDelete || !activeInstanceId) return;
+
+ setIsDeleting(true);
+ try {
+ await invoke("delete_version", {
+ instanceId: activeInstanceId,
+ versionId: versionToDelete,
+ });
+
+ if (selectedVersion === versionToDelete) {
+ setSelectedVersion("");
+ }
+
+ setShowDeleteDialog(false);
+ setVersionToDelete(null);
+ toast.success("Version deleted successfully");
+
+ await loadVersions(activeInstanceId);
+ await loadInstalledModdedVersions();
+ } catch (e) {
+ console.error("Failed to delete version:", e);
+ toast.error(`Failed to delete version: ${e}`);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ // Show delete confirmation dialog
+ const showDeleteConfirmation = (versionId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setVersionToDelete(versionId);
+ setShowDeleteDialog(true);
+ };
+
+ // Setup event listeners for version updates
+ useEffect(() => {
+ let unlisteners: UnlistenFn[] = [];
+
+ const setupEventListeners = async () => {
+ try {
+ const versionDeletedUnlisten = await listen(
+ "version-deleted",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const downloadCompleteUnlisten = await listen(
+ "download-complete",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const versionInstalledUnlisten = await listen(
+ "version-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const fabricInstalledUnlisten = await listen(
+ "fabric-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const forgeInstalledUnlisten = await listen(
+ "forge-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ unlisteners = [
+ versionDeletedUnlisten,
+ downloadCompleteUnlisten,
+ versionInstalledUnlisten,
+ fabricInstalledUnlisten,
+ forgeInstalledUnlisten,
+ ];
+ } catch (e) {
+ console.error("Failed to setup event listeners:", e);
+ }
+ };
+
+ setupEventListeners();
+ loadInstalledModdedVersions();
+
+ return () => {
+ unlisteners.forEach((unlisten) => {
+ unlisten();
+ });
+ };
+ }, [activeInstanceId, loadVersions, loadInstalledModdedVersions]);
+
+ // Load metadata when selected version changes
+ useEffect(() => {
+ if (selectedVersion) {
+ loadVersionMetadata(selectedVersion);
+ } else {
+ setSelectedVersionMetadata(null);
+ }
+ }, [selectedVersion, loadVersionMetadata]);
+
+ return (
+ <div className="h-full flex flex-col p-6 overflow-hidden">
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/60">
+ Version Manager
+ </h2>
+ <div className="text-sm dark:text-white/40 text-black/50">
+ Select a version to play or modify
+ </div>
+ </div>
+
+ <div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 overflow-hidden">
+ {/* Left: Version List */}
+ <div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
+ {/* Search and Filters */}
+ <div className="flex gap-3">
+ <div className="relative flex-1">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ type="text"
+ placeholder="Search versions..."
+ className="pl-9 bg-white/60 dark:bg-black/20 border-black/10 dark:border-white/10 dark:text-white text-gray-900 placeholder-black/30 dark:placeholder-white/30 focus:border-indigo-500/50 dark:focus:bg-black/40 focus:bg-white/80 backdrop-blur-sm"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+ </div>
+
+ {/* Type Filter Tabs */}
+ <Tabs
+ value={typeFilter}
+ onValueChange={(v) => setTypeFilter(v as TypeFilter)}
+ className="w-full"
+ >
+ <TabsList className="grid grid-cols-4 bg-white/60 dark:bg-black/20 border-black/5 dark:border-white/5">
+ <TabsTrigger value="all">All</TabsTrigger>
+ <TabsTrigger value="release">Release</TabsTrigger>
+ <TabsTrigger value="snapshot">Snapshot</TabsTrigger>
+ <TabsTrigger value="installed">Installed</TabsTrigger>
+ </TabsList>
+ </Tabs>
+
+ {/* Version List */}
+ <ScrollArea className="flex-1 pr-2">
+ {versions.length === 0 ? (
+ <div className="flex items-center justify-center h-40 dark:text-white/30 text-black/30 italic animate-pulse">
+ Loading versions...
+ </div>
+ ) : filteredVersions.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-40 dark:text-white/30 text-black/30 gap-2">
+ <span className="text-2xl">👻</span>
+ <span>No matching versions found</span>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredVersions.map((version) => {
+ const badge = getVersionBadge(version.type);
+ const isSelected = selectedVersion === version.id;
+
+ return (
+ <Card
+ key={version.id}
+ className={`w-full cursor-pointer transition-all duration-200 relative overflow-hidden group ${
+ isSelected
+ ? "border-indigo-200 dark:border-indigo-500/50 bg-indigo-50 dark:bg-indigo-600/20 shadow-[0_0_20px_rgba(99,102,241,0.2)]"
+ : "border-black/5 dark:border-white/5 bg-white/40 dark:bg-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={() => setSelectedVersion(version.id)}
+ >
+ {isSelected && (
+ <div className="absolute inset-0 bg-linear-to-r from-indigo-500/10 to-transparent pointer-events-none" />
+ )}
+
+ <CardContent className="p-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4 flex-1">
+ <Badge
+ variant={badge.variant}
+ className={badge.className}
+ >
+ {badge.text}
+ </Badge>
+ <div className="flex-1">
+ <div
+ className={`font-bold font-mono text-lg tracking-tight ${
+ isSelected
+ ? "text-black dark:text-white"
+ : "text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white"
+ }`}
+ >
+ {version.id}
+ </div>
+ <div className="flex items-center gap-2 mt-0.5">
+ {version.releaseTime &&
+ version.type !== "fabric" &&
+ version.type !== "forge" && (
+ <div className="text-xs dark:text-white/30 text-black/30">
+ {new Date(
+ version.releaseTime,
+ ).toLocaleDateString()}
+ </div>
+ )}
+ {version.javaVersion && (
+ <div className="flex items-center gap-1 text-xs dark:text-white/40 text-black/40">
+ <Coffee className="h-3 w-3 opacity-60" />
+ <span className="font-medium">
+ Java {version.javaVersion}
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {version.isInstalled && (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="opacity-0 group-hover:opacity-100 transition-opacity text-red-500 dark:text-red-400 hover:bg-red-500/10 dark:hover:bg-red-500/20"
+ onClick={(e) =>
+ showDeleteConfirmation(version.id, e)
+ }
+ title="Delete version"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+ })}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+
+ {/* Right: Version Details */}
+ <div className="flex flex-col gap-6">
+ <Card className="border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 backdrop-blur-sm">
+ <CardHeader>
+ <CardTitle className="text-lg">Version Details</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {selectedVersion ? (
+ <>
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Selected Version
+ </div>
+ <div className="font-mono text-xl font-bold">
+ {selectedVersion}
+ </div>
+ </div>
+
+ {isLoadingMetadata ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="text-sm">Loading metadata...</span>
+ </div>
+ ) : selectedVersionMetadata ? (
+ <div className="space-y-3">
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Installation Status
+ </div>
+ <Badge
+ variant={
+ selectedVersionMetadata.isInstalled
+ ? "default"
+ : "outline"
+ }
+ >
+ {selectedVersionMetadata.isInstalled
+ ? "Installed"
+ : "Not Installed"}
+ </Badge>
+ </div>
+
+ {selectedVersionMetadata.javaVersion && (
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Java Version
+ </div>
+ <div className="flex items-center gap-2">
+ <Coffee className="h-4 w-4" />
+ <span>
+ Java {selectedVersionMetadata.javaVersion}
+ </span>
+ </div>
+ </div>
+ )}
+
+ {!selectedVersionMetadata.isInstalled && (
+ <Button
+ className="w-full"
+ onClick={() => setShowModLoaderSelector(true)}
+ >
+ Install with Mod Loader
+ </Button>
+ )}
+ </div>
+ ) : null}
+ </>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ Select a version to view details
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* Mod Loader Installation */}
+ {showModLoaderSelector && selectedBaseVersion && (
+ <Card className="border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 backdrop-blur-sm">
+ <CardHeader>
+ <CardTitle className="text-lg">Install Mod Loader</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="text-sm text-muted-foreground">
+ Install {selectedBaseVersion} with Fabric or Forge
+ </div>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ className="flex-1"
+ onClick={async () => {
+ if (!activeInstanceId) return;
+ try {
+ await invoke("install_fabric", {
+ instanceId: activeInstanceId,
+ gameVersion: selectedBaseVersion,
+ loaderVersion: "latest",
+ });
+ toast.success("Fabric installation started");
+ setShowModLoaderSelector(false);
+ } catch (e) {
+ console.error("Failed to install Fabric:", e);
+ toast.error(`Failed to install Fabric: ${e}`);
+ }
+ }}
+ >
+ Install Fabric
+ </Button>
+ <Button
+ variant="outline"
+ className="flex-1"
+ onClick={async () => {
+ if (!activeInstanceId) return;
+ try {
+ await invoke("install_forge", {
+ instanceId: activeInstanceId,
+ gameVersion: selectedBaseVersion,
+ installerVersion: "latest",
+ });
+ toast.success("Forge installation started");
+ setShowModLoaderSelector(false);
+ } catch (e) {
+ console.error("Failed to install Forge:", e);
+ toast.error(`Failed to install Forge: ${e}`);
+ }
+ }}
+ >
+ Install Forge
+ </Button>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setShowModLoaderSelector(false)}
+ >
+ Cancel
+ </Button>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ </div>
+
+ {/* Delete Confirmation Dialog */}
+ <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Version</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete version "{versionToDelete}"? This
+ action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setShowDeleteDialog(false);
+ setVersionToDelete(null);
+ }}
+ disabled={isDeleting}
+ >
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDeleteVersion}
+ disabled={isDeleting}
+ >
+ {isDeleting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Deleting...
+ </>
+ ) : (
+ "Delete"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui/src/stores/assistant-store.ts b/packages/ui/src/stores/assistant-store.ts
new file mode 100644
index 0000000..180031b
--- /dev/null
+++ b/packages/ui/src/stores/assistant-store.ts
@@ -0,0 +1,201 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { create } from "zustand";
+import type { GenerationStats, StreamChunk } from "@/types/bindings/assistant";
+
+export interface Message {
+ role: "user" | "assistant" | "system";
+ content: string;
+ stats?: GenerationStats;
+}
+
+interface AssistantState {
+ // State
+ messages: Message[];
+ isProcessing: boolean;
+ isProviderHealthy: boolean | undefined;
+ streamingContent: string;
+ initialized: boolean;
+ streamUnlisten: UnlistenFn | null;
+
+ // Actions
+ init: () => Promise<void>;
+ checkHealth: () => Promise<void>;
+ sendMessage: (
+ content: string,
+ isEnabled: boolean,
+ provider: string,
+ endpoint: string,
+ ) => Promise<void>;
+ finishStreaming: () => void;
+ clearHistory: () => void;
+ setMessages: (messages: Message[]) => void;
+ setIsProcessing: (isProcessing: boolean) => void;
+ setIsProviderHealthy: (isProviderHealthy: boolean | undefined) => void;
+ setStreamingContent: (streamingContent: string) => void;
+}
+
+export const useAssistantStore = create<AssistantState>((set, get) => ({
+ // Initial state
+ messages: [],
+ isProcessing: false,
+ isProviderHealthy: false,
+ streamingContent: "",
+ initialized: false,
+ streamUnlisten: null,
+
+ // Actions
+ init: async () => {
+ const { initialized } = get();
+ if (initialized) return;
+ set({ initialized: true });
+ await get().checkHealth();
+ },
+
+ checkHealth: async () => {
+ try {
+ const isHealthy = await invoke<boolean>("assistant_check_health");
+ set({ isProviderHealthy: isHealthy });
+ } catch (e) {
+ console.error("Failed to check provider health:", e);
+ set({ isProviderHealthy: false });
+ }
+ },
+
+ finishStreaming: () => {
+ const { streamUnlisten } = get();
+ set({ isProcessing: false, streamingContent: "" });
+
+ if (streamUnlisten) {
+ streamUnlisten();
+ set({ streamUnlisten: null });
+ }
+ },
+
+ sendMessage: async (content, isEnabled, provider, endpoint) => {
+ if (!content.trim()) return;
+
+ const { messages } = get();
+
+ if (!isEnabled) {
+ const newMessage: Message = {
+ role: "assistant",
+ content: "Assistant is disabled. Enable it in Settings > AI Assistant.",
+ };
+ set({ messages: [...messages, { role: "user", content }, newMessage] });
+ return;
+ }
+
+ // Add user message
+ const userMessage: Message = { role: "user", content };
+ const updatedMessages = [...messages, userMessage];
+ set({
+ messages: updatedMessages,
+ isProcessing: true,
+ streamingContent: "",
+ });
+
+ // Add empty assistant message for streaming
+ const assistantMessage: Message = { role: "assistant", content: "" };
+ const withAssistantMessage = [...updatedMessages, assistantMessage];
+ set({ messages: withAssistantMessage });
+
+ try {
+ // Set up stream listener
+ const unlisten = await listen<StreamChunk>(
+ "assistant-stream",
+ (event) => {
+ const chunk = event.payload;
+ const currentState = get();
+
+ if (chunk.content) {
+ const newStreamingContent =
+ currentState.streamingContent + chunk.content;
+ const currentMessages = [...currentState.messages];
+ const lastIdx = currentMessages.length - 1;
+
+ if (lastIdx >= 0 && currentMessages[lastIdx].role === "assistant") {
+ currentMessages[lastIdx] = {
+ ...currentMessages[lastIdx],
+ content: newStreamingContent,
+ };
+ set({
+ streamingContent: newStreamingContent,
+ messages: currentMessages,
+ });
+ }
+ }
+
+ if (chunk.done) {
+ const finalMessages = [...currentState.messages];
+ const lastIdx = finalMessages.length - 1;
+
+ if (
+ chunk.stats &&
+ lastIdx >= 0 &&
+ finalMessages[lastIdx].role === "assistant"
+ ) {
+ finalMessages[lastIdx] = {
+ ...finalMessages[lastIdx],
+ stats: chunk.stats,
+ };
+ set({ messages: finalMessages });
+ }
+
+ get().finishStreaming();
+ }
+ },
+ );
+
+ set({ streamUnlisten: unlisten });
+
+ // Start streaming chat
+ await invoke<string>("assistant_chat_stream", {
+ messages: withAssistantMessage.slice(0, -1), // Exclude the empty assistant message
+ });
+ } catch (e) {
+ console.error("Failed to send message:", e);
+ const errorMessage = e instanceof Error ? e.message : String(e);
+
+ let helpText = "";
+ if (provider === "ollama") {
+ helpText = `\n\nPlease ensure Ollama is running at ${endpoint}.`;
+ } else if (provider === "openai") {
+ helpText = "\n\nPlease check your OpenAI API key in Settings.";
+ }
+
+ // Update the last message with error
+ const currentMessages = [...get().messages];
+ const lastIdx = currentMessages.length - 1;
+ if (lastIdx >= 0 && currentMessages[lastIdx].role === "assistant") {
+ currentMessages[lastIdx] = {
+ role: "assistant",
+ content: `Error: ${errorMessage}${helpText}`,
+ };
+ set({ messages: currentMessages });
+ }
+
+ get().finishStreaming();
+ }
+ },
+
+ clearHistory: () => {
+ set({ messages: [], streamingContent: "" });
+ },
+
+ setMessages: (messages) => {
+ set({ messages });
+ },
+
+ setIsProcessing: (isProcessing) => {
+ set({ isProcessing });
+ },
+
+ setIsProviderHealthy: (isProviderHealthy) => {
+ set({ isProviderHealthy });
+ },
+
+ setStreamingContent: (streamingContent) => {
+ set({ streamingContent });
+ },
+}));
diff --git a/packages/ui/src/stores/assistant.svelte.ts b/packages/ui/src/stores/assistant.svelte.ts
deleted file mode 100644
index a3f47ea..0000000
--- a/packages/ui/src/stores/assistant.svelte.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-
-export interface GenerationStats {
- total_duration: number;
- load_duration: number;
- prompt_eval_count: number;
- prompt_eval_duration: number;
- eval_count: number;
- eval_duration: number;
-}
-
-export interface Message {
- role: "user" | "assistant" | "system";
- content: string;
- stats?: GenerationStats;
-}
-
-interface StreamChunk {
- content: string;
- done: boolean;
- stats?: GenerationStats;
-}
-
-// Module-level state using $state
-let messages = $state<Message[]>([]);
-let isProcessing = $state(false);
-let isProviderHealthy = $state(false);
-let streamingContent = "";
-let initialized = false;
-let streamUnlisten: UnlistenFn | null = null;
-
-async function init() {
- if (initialized) return;
- initialized = true;
- await checkHealth();
-}
-
-async function checkHealth() {
- try {
- isProviderHealthy = await invoke("assistant_check_health");
- } catch (e) {
- console.error("Failed to check provider health:", e);
- isProviderHealthy = false;
- }
-}
-
-function finishStreaming() {
- isProcessing = false;
- streamingContent = "";
- if (streamUnlisten) {
- streamUnlisten();
- streamUnlisten = null;
- }
-}
-
-async function sendMessage(
- content: string,
- isEnabled: boolean,
- provider: string,
- endpoint: string,
-) {
- if (!content.trim()) return;
- if (!isEnabled) {
- messages = [
- ...messages,
- {
- role: "assistant",
- content: "Assistant is disabled. Enable it in Settings > AI Assistant.",
- },
- ];
- return;
- }
-
- // Add user message
- messages = [...messages, { role: "user", content }];
- isProcessing = true;
- streamingContent = "";
-
- // Add empty assistant message for streaming
- messages = [...messages, { role: "assistant", content: "" }];
-
- try {
- // Set up stream listener
- streamUnlisten = await listen<StreamChunk>("assistant-stream", (event) => {
- const chunk = event.payload;
-
- if (chunk.content) {
- streamingContent += chunk.content;
- // Update the last message (assistant's response)
- const lastIdx = messages.length - 1;
- if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
- messages[lastIdx] = {
- ...messages[lastIdx],
- content: streamingContent,
- };
- // Trigger reactivity
- messages = [...messages];
- }
- }
-
- if (chunk.done) {
- if (chunk.stats) {
- const lastIdx = messages.length - 1;
- if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
- messages[lastIdx] = {
- ...messages[lastIdx],
- stats: chunk.stats,
- };
- messages = [...messages];
- }
- }
- finishStreaming();
- }
- });
-
- // Start streaming chat
- await invoke<string>("assistant_chat_stream", {
- messages: messages.slice(0, -1), // Exclude the empty assistant message
- });
- } catch (e) {
- console.error("Failed to send message:", e);
- const errorMessage = e instanceof Error ? e.message : String(e);
-
- let helpText = "";
- if (provider === "ollama") {
- helpText = `\n\nPlease ensure Ollama is running at ${endpoint}.`;
- } else if (provider === "openai") {
- helpText = "\n\nPlease check your OpenAI API key in Settings.";
- }
-
- // Update the last message with error
- const lastIdx = messages.length - 1;
- if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
- messages[lastIdx] = {
- role: "assistant",
- content: `Error: ${errorMessage}${helpText}`,
- };
- messages = [...messages];
- }
-
- finishStreaming();
- }
-}
-
-function clearHistory() {
- messages = [];
- streamingContent = "";
-}
-
-// Export as an object with getters for reactive access
-export const assistantState = {
- get messages() {
- return messages;
- },
- get isProcessing() {
- return isProcessing;
- },
- get isProviderHealthy() {
- return isProviderHealthy;
- },
- init,
- checkHealth,
- sendMessage,
- clearHistory,
-};
diff --git a/packages/ui/src/stores/auth-store.ts b/packages/ui/src/stores/auth-store.ts
new file mode 100644
index 0000000..bf7e3c5
--- /dev/null
+++ b/packages/ui/src/stores/auth-store.ts
@@ -0,0 +1,296 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { open } from "@tauri-apps/plugin-shell";
+import { toast } from "sonner";
+import { create } from "zustand";
+import type { Account, DeviceCodeResponse } from "../types/bindings/auth";
+
+interface AuthState {
+ // State
+ currentAccount: Account | null;
+ isLoginModalOpen: boolean;
+ isLogoutConfirmOpen: boolean;
+ loginMode: "select" | "offline" | "microsoft";
+ offlineUsername: string;
+ deviceCodeData: DeviceCodeResponse | null;
+ msLoginLoading: boolean;
+ msLoginStatus: string;
+
+ // Private state
+ pollInterval: ReturnType<typeof setInterval> | null;
+ isPollingRequestActive: boolean;
+ authProgressUnlisten: UnlistenFn | null;
+
+ // Actions
+ checkAccount: () => Promise<void>;
+ openLoginModal: () => void;
+ openLogoutConfirm: () => void;
+ cancelLogout: () => void;
+ confirmLogout: () => Promise<void>;
+ closeLoginModal: () => void;
+ resetLoginState: () => void;
+ performOfflineLogin: () => Promise<void>;
+ startMicrosoftLogin: () => Promise<void>;
+ checkLoginStatus: (deviceCode: string) => Promise<void>;
+ stopPolling: () => void;
+ cancelMicrosoftLogin: () => void;
+ setLoginMode: (mode: "select" | "offline" | "microsoft") => void;
+ setOfflineUsername: (username: string) => void;
+}
+
+export const useAuthStore = create<AuthState>((set, get) => ({
+ // Initial state
+ currentAccount: null,
+ isLoginModalOpen: false,
+ isLogoutConfirmOpen: false,
+ loginMode: "select",
+ offlineUsername: "",
+ deviceCodeData: null,
+ msLoginLoading: false,
+ msLoginStatus: "Waiting for authorization...",
+
+ // Private state
+ pollInterval: null,
+ isPollingRequestActive: false,
+ authProgressUnlisten: null,
+
+ // Actions
+ checkAccount: async () => {
+ try {
+ const acc = await invoke<Account | null>("get_active_account");
+ set({ currentAccount: acc });
+ } catch (error) {
+ console.error("Failed to check account:", error);
+ }
+ },
+
+ openLoginModal: () => {
+ const { currentAccount } = get();
+ if (currentAccount) {
+ // Show custom logout confirmation dialog
+ set({ isLogoutConfirmOpen: true });
+ return;
+ }
+ get().resetLoginState();
+ set({ isLoginModalOpen: true });
+ },
+
+ openLogoutConfirm: () => {
+ set({ isLogoutConfirmOpen: true });
+ },
+
+ cancelLogout: () => {
+ set({ isLogoutConfirmOpen: false });
+ },
+
+ confirmLogout: async () => {
+ set({ isLogoutConfirmOpen: false });
+ try {
+ await invoke("logout");
+ set({ currentAccount: null });
+ } catch (error) {
+ console.error("Logout failed:", error);
+ }
+ },
+
+ closeLoginModal: () => {
+ get().stopPolling();
+ set({ isLoginModalOpen: false });
+ },
+
+ resetLoginState: () => {
+ set({
+ loginMode: "select",
+ offlineUsername: "",
+ deviceCodeData: null,
+ msLoginLoading: false,
+ msLoginStatus: "Waiting for authorization...",
+ });
+ },
+
+ performOfflineLogin: async () => {
+ const { offlineUsername } = get();
+ if (!offlineUsername.trim()) return;
+
+ try {
+ const account = await invoke<Account>("login_offline", {
+ username: offlineUsername,
+ });
+ set({
+ currentAccount: account,
+ isLoginModalOpen: false,
+ offlineUsername: "",
+ });
+ } catch (error) {
+ // Keep UI-friendly behavior consistent with prior code
+ alert("Login failed: " + String(error));
+ }
+ },
+
+ startMicrosoftLogin: async () => {
+ // Prepare UI state
+ set({
+ msLoginLoading: true,
+ msLoginStatus: "Waiting for authorization...",
+ loginMode: "microsoft",
+ deviceCodeData: null,
+ });
+
+ // Listen to general launcher logs so we can display progress to the user.
+ // The backend emits logs via "launcher-log"; using that keeps this store decoupled
+ // from a dedicated auth event channel (backend may reuse launcher-log).
+ try {
+ const unlisten = await listen("launcher-log", (event) => {
+ const payload = event.payload;
+ // Normalize payload to string if possible
+ const message =
+ typeof payload === "string"
+ ? payload
+ : (payload?.toString?.() ?? JSON.stringify(payload));
+ set({ msLoginStatus: message });
+ });
+ set({ authProgressUnlisten: unlisten });
+ } catch (err) {
+ console.warn("Failed to attach launcher-log listener:", err);
+ }
+
+ try {
+ const deviceCodeData = await invoke<DeviceCodeResponse>(
+ "start_microsoft_login",
+ );
+ set({ deviceCodeData });
+
+ if (deviceCodeData) {
+ // Try to copy user code to clipboard for convenience (best-effort)
+ try {
+ await navigator.clipboard?.writeText(deviceCodeData.userCode ?? "");
+ } catch (err) {
+ // ignore clipboard errors
+ console.debug("Clipboard copy failed:", err);
+ }
+
+ // Open verification URI in default browser
+ try {
+ if (deviceCodeData.verificationUri) {
+ await open(deviceCodeData.verificationUri);
+ }
+ } catch (err) {
+ console.debug("Failed to open verification URI:", err);
+ }
+
+ // Start polling for completion
+ // `interval` from the bindings is a bigint (seconds). Convert safely to number.
+ const intervalSeconds =
+ deviceCodeData.interval !== undefined &&
+ deviceCodeData.interval !== null
+ ? Number(deviceCodeData.interval)
+ : 5;
+ const intervalMs = intervalSeconds * 1000;
+ const pollInterval = setInterval(
+ () => get().checkLoginStatus(deviceCodeData.deviceCode),
+ intervalMs,
+ );
+ set({ pollInterval });
+ }
+ } catch (error) {
+ toast.error(`Failed to start Microsoft login: ${error}`);
+ set({ loginMode: "select" });
+ // cleanup listener if present
+ const { authProgressUnlisten } = get();
+ if (authProgressUnlisten) {
+ authProgressUnlisten();
+ set({ authProgressUnlisten: null });
+ }
+ } finally {
+ set({ msLoginLoading: false });
+ }
+ },
+
+ checkLoginStatus: async (deviceCode: string) => {
+ const { isPollingRequestActive } = get();
+ if (isPollingRequestActive) return;
+
+ set({ isPollingRequestActive: true });
+
+ try {
+ const account = await invoke<Account>("complete_microsoft_login", {
+ deviceCode,
+ });
+
+ // On success, stop polling and cleanup listener
+ get().stopPolling();
+ const { authProgressUnlisten } = get();
+ if (authProgressUnlisten) {
+ authProgressUnlisten();
+ set({ authProgressUnlisten: null });
+ }
+
+ set({
+ currentAccount: account,
+ isLoginModalOpen: false,
+ });
+ } catch (error: unknown) {
+ const errStr = String(error);
+ if (errStr.includes("authorization_pending")) {
+ // Still waiting — keep polling
+ } else {
+ set({ msLoginStatus: "Error: " + errStr });
+
+ if (
+ errStr.includes("expired_token") ||
+ errStr.includes("access_denied")
+ ) {
+ // Terminal errors — stop polling and reset state
+ get().stopPolling();
+ const { authProgressUnlisten } = get();
+ if (authProgressUnlisten) {
+ authProgressUnlisten();
+ set({ authProgressUnlisten: null });
+ }
+ alert("Login failed: " + errStr);
+ set({ loginMode: "select" });
+ }
+ }
+ } finally {
+ set({ isPollingRequestActive: false });
+ }
+ },
+
+ stopPolling: () => {
+ const { pollInterval, authProgressUnlisten } = get();
+ if (pollInterval) {
+ try {
+ clearInterval(pollInterval);
+ } catch (err) {
+ console.debug("Failed to clear poll interval:", err);
+ }
+ set({ pollInterval: null });
+ }
+ if (authProgressUnlisten) {
+ try {
+ authProgressUnlisten();
+ } catch (err) {
+ console.debug("Failed to unlisten auth progress:", err);
+ }
+ set({ authProgressUnlisten: null });
+ }
+ },
+
+ cancelMicrosoftLogin: () => {
+ get().stopPolling();
+ set({
+ deviceCodeData: null,
+ msLoginLoading: false,
+ msLoginStatus: "",
+ loginMode: "select",
+ });
+ },
+
+ setLoginMode: (mode: "select" | "offline" | "microsoft") => {
+ set({ loginMode: mode });
+ },
+
+ setOfflineUsername: (username: string) => {
+ set({ offlineUsername: username });
+ },
+}));
diff --git a/packages/ui/src/stores/auth.svelte.ts b/packages/ui/src/stores/auth.svelte.ts
deleted file mode 100644
index 1b613a7..0000000
--- a/packages/ui/src/stores/auth.svelte.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import { open } from "@tauri-apps/plugin-shell";
-import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-import type { Account, DeviceCodeResponse } from "../types";
-import { uiState } from "./ui.svelte";
-import { logsState } from "./logs.svelte";
-
-export class AuthState {
- currentAccount = $state<Account | null>(null);
- isLoginModalOpen = $state(false);
- isLogoutConfirmOpen = $state(false);
- loginMode = $state<"select" | "offline" | "microsoft">("select");
- offlineUsername = $state("");
- deviceCodeData = $state<DeviceCodeResponse | null>(null);
- msLoginLoading = $state(false);
- msLoginStatus = $state("Waiting for authorization...");
-
- private pollInterval: ReturnType<typeof setInterval> | null = null;
- private isPollingRequestActive = false;
- private authProgressUnlisten: UnlistenFn | null = null;
-
- async checkAccount() {
- try {
- const acc = await invoke("get_active_account");
- this.currentAccount = acc as Account | null;
- } catch (e) {
- console.error("Failed to check account:", e);
- }
- }
-
- openLoginModal() {
- if (this.currentAccount) {
- // Show custom logout confirmation dialog
- this.isLogoutConfirmOpen = true;
- return;
- }
- this.resetLoginState();
- this.isLoginModalOpen = true;
- }
-
- cancelLogout() {
- this.isLogoutConfirmOpen = false;
- }
-
- async confirmLogout() {
- this.isLogoutConfirmOpen = false;
- try {
- await invoke("logout");
- this.currentAccount = null;
- uiState.setStatus("Logged out successfully");
- } catch (e) {
- console.error("Logout failed:", e);
- }
- }
-
- closeLoginModal() {
- this.stopPolling();
- this.isLoginModalOpen = false;
- }
-
- resetLoginState() {
- this.loginMode = "select";
- this.offlineUsername = "";
- this.deviceCodeData = null;
- this.msLoginLoading = false;
- }
-
- async performOfflineLogin() {
- if (!this.offlineUsername) return;
- try {
- this.currentAccount = (await invoke("login_offline", {
- username: this.offlineUsername,
- })) as Account;
- this.isLoginModalOpen = false;
- } catch (e) {
- alert("Login failed: " + e);
- }
- }
-
- async startMicrosoftLogin() {
- this.loginMode = "microsoft";
- this.msLoginLoading = true;
- this.msLoginStatus = "Waiting for authorization...";
- this.stopPolling();
-
- // Setup auth progress listener
- this.setupAuthProgressListener();
-
- try {
- this.deviceCodeData = (await invoke("start_microsoft_login")) as DeviceCodeResponse;
-
- if (this.deviceCodeData) {
- try {
- await navigator.clipboard.writeText(this.deviceCodeData.user_code);
- } catch (e) {
- console.error("Clipboard failed", e);
- }
-
- open(this.deviceCodeData.verification_uri);
- logsState.addLog(
- "info",
- "Auth",
- "Microsoft login started, waiting for browser authorization...",
- );
-
- console.log("Starting polling for token...");
- const intervalMs = (this.deviceCodeData.interval || 5) * 1000;
- this.pollInterval = setInterval(
- () => this.checkLoginStatus(this.deviceCodeData!.device_code),
- intervalMs,
- );
- }
- } catch (e) {
- logsState.addLog("error", "Auth", `Failed to start Microsoft login: ${e}`);
- alert("Failed to start Microsoft login: " + e);
- this.loginMode = "select";
- } finally {
- this.msLoginLoading = false;
- }
- }
-
- private async setupAuthProgressListener() {
- // Clean up previous listener if exists
- if (this.authProgressUnlisten) {
- this.authProgressUnlisten();
- this.authProgressUnlisten = null;
- }
-
- this.authProgressUnlisten = await listen<string>("auth-progress", (event) => {
- const message = event.payload;
- this.msLoginStatus = message;
- logsState.addLog("info", "Auth", message);
- });
- }
-
- private cleanupAuthListener() {
- if (this.authProgressUnlisten) {
- this.authProgressUnlisten();
- this.authProgressUnlisten = null;
- }
- }
-
- stopPolling() {
- if (this.pollInterval) {
- clearInterval(this.pollInterval);
- this.pollInterval = null;
- }
- }
-
- async checkLoginStatus(deviceCode: string) {
- if (this.isPollingRequestActive) return;
- this.isPollingRequestActive = true;
-
- console.log("Polling Microsoft API...");
- try {
- this.currentAccount = (await invoke("complete_microsoft_login", {
- deviceCode,
- })) as Account;
-
- console.log("Login Successful!", this.currentAccount);
- this.stopPolling();
- this.cleanupAuthListener();
- this.isLoginModalOpen = false;
- logsState.addLog(
- "info",
- "Auth",
- `Login successful! Welcome, ${this.currentAccount.username}`,
- );
- uiState.setStatus("Welcome back, " + this.currentAccount.username);
- } catch (e: any) {
- const errStr = e.toString();
- if (errStr.includes("authorization_pending")) {
- console.log("Status: Waiting for user to authorize...");
- } else {
- console.error("Polling Error:", errStr);
- this.msLoginStatus = "Error: " + errStr;
- logsState.addLog("error", "Auth", `Login error: ${errStr}`);
-
- if (errStr.includes("expired_token") || errStr.includes("access_denied")) {
- this.stopPolling();
- this.cleanupAuthListener();
- alert("Login failed: " + errStr);
- this.loginMode = "select";
- }
- }
- } finally {
- this.isPollingRequestActive = false;
- }
- }
-}
-
-export const authState = new AuthState();
diff --git a/packages/ui/src/stores/game-store.ts b/packages/ui/src/stores/game-store.ts
new file mode 100644
index 0000000..fa0f9f8
--- /dev/null
+++ b/packages/ui/src/stores/game-store.ts
@@ -0,0 +1,101 @@
+import { toast } from "sonner";
+import { create } from "zustand";
+import { getVersions } from "@/client";
+import type { Version } from "@/types/bindings/manifest";
+
+interface GameState {
+ // State
+ versions: Version[];
+ selectedVersion: string;
+
+ // Computed property
+ latestRelease: Version | undefined;
+
+ // Actions
+ loadVersions: (instanceId?: string) => Promise<void>;
+ startGame: (
+ currentAccount: any,
+ openLoginModal: () => void,
+ activeInstanceId: string | null,
+ setView: (view: any) => void,
+ ) => Promise<void>;
+ setSelectedVersion: (version: string) => void;
+ setVersions: (versions: Version[]) => void;
+}
+
+export const useGameStore = create<GameState>((set, get) => ({
+ // Initial state
+ versions: [],
+ selectedVersion: "",
+
+ // Computed property
+ get latestRelease() {
+ return get().versions.find((v) => v.type === "release");
+ },
+
+ // Actions
+ loadVersions: async (instanceId?: string) => {
+ console.log("Loading versions for instance:", instanceId);
+ try {
+ // Ask the backend for known versions (optionally scoped to an instance).
+ // The Tauri command `get_versions` is expected to return an array of `Version`.
+ const versions = await getVersions();
+ set({ versions: versions ?? [] });
+ } catch (e) {
+ console.error("Failed to load versions:", e);
+ // Keep the store consistent on error by clearing versions.
+ set({ versions: [] });
+ }
+ },
+
+ startGame: async (
+ currentAccount,
+ openLoginModal,
+ activeInstanceId,
+ setView,
+ ) => {
+ const { selectedVersion } = get();
+
+ if (!currentAccount) {
+ alert("Please login first!");
+ openLoginModal();
+ return;
+ }
+
+ if (!selectedVersion) {
+ alert("Please select a version!");
+ return;
+ }
+
+ if (!activeInstanceId) {
+ alert("Please select an instance first!");
+ setView("instances");
+ return;
+ }
+
+ toast.info("Preparing to launch " + selectedVersion + "...");
+
+ try {
+ // Note: In production, this would call Tauri invoke
+ // const msg = await invoke<string>("start_game", {
+ // instanceId: activeInstanceId,
+ // versionId: selectedVersion,
+ // });
+
+ // Simulate success
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ toast.success("Game started successfully!");
+ } catch (e) {
+ console.error(e);
+ toast.error(`Error: ${e}`);
+ }
+ },
+
+ setSelectedVersion: (version: string) => {
+ set({ selectedVersion: version });
+ },
+
+ setVersions: (versions: Version[]) => {
+ set({ versions });
+ },
+}));
diff --git a/packages/ui/src/stores/game.svelte.ts b/packages/ui/src/stores/game.svelte.ts
deleted file mode 100644
index 504d108..0000000
--- a/packages/ui/src/stores/game.svelte.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import type { Version } from "../types";
-import { uiState } from "./ui.svelte";
-import { authState } from "./auth.svelte";
-import { instancesState } from "./instances.svelte";
-
-export class GameState {
- versions = $state<Version[]>([]);
- selectedVersion = $state("");
-
- constructor() {
- // Constructor intentionally empty
- // Instance switching handled in App.svelte with $effect
- }
-
- get latestRelease() {
- return this.versions.find((v) => v.type === "release");
- }
-
- async loadVersions(instanceId?: string) {
- const id = instanceId || instancesState.activeInstanceId;
- if (!id) {
- this.versions = [];
- return;
- }
-
- try {
- this.versions = await invoke<Version[]>("get_versions", {
- instanceId: id,
- });
- // Don't auto-select version here - let BottomBar handle version selection
- // based on installed versions only
- } catch (e) {
- console.error("Failed to fetch versions:", e);
- uiState.setStatus("Error fetching versions: " + e);
- }
- }
-
- async startGame() {
- if (!authState.currentAccount) {
- alert("Please login first!");
- authState.openLoginModal();
- return;
- }
-
- if (!this.selectedVersion) {
- alert("Please select a version!");
- return;
- }
-
- if (!instancesState.activeInstanceId) {
- alert("Please select an instance first!");
- uiState.setView("instances");
- return;
- }
-
- uiState.setStatus("Preparing to launch " + this.selectedVersion + "...");
- console.log(
- "Invoking start_game for version:",
- this.selectedVersion,
- "instance:",
- instancesState.activeInstanceId,
- );
- try {
- const msg = await invoke<string>("start_game", {
- instanceId: instancesState.activeInstanceId,
- versionId: this.selectedVersion,
- });
- console.log("Response:", msg);
- uiState.setStatus(msg);
- } catch (e) {
- console.error(e);
- uiState.setStatus("Error: " + e);
- }
- }
-}
-
-export const gameState = new GameState();
diff --git a/packages/ui/src/stores/instances.svelte.ts b/packages/ui/src/stores/instances.svelte.ts
deleted file mode 100644
index f4ac4e9..0000000
--- a/packages/ui/src/stores/instances.svelte.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import type { Instance } from "../types";
-import { uiState } from "./ui.svelte";
-
-export class InstancesState {
- instances = $state<Instance[]>([]);
- activeInstanceId = $state<string | null>(null);
- get activeInstance(): Instance | null {
- if (!this.activeInstanceId) return null;
- return this.instances.find((i) => i.id === this.activeInstanceId) || null;
- }
-
- async loadInstances() {
- try {
- this.instances = await invoke<Instance[]>("list_instances");
- const active = await invoke<Instance | null>("get_active_instance");
- if (active) {
- this.activeInstanceId = active.id;
- } else if (this.instances.length > 0) {
- // If no active instance but instances exist, set the first one as active
- await this.setActiveInstance(this.instances[0].id);
- }
- } catch (e) {
- console.error("Failed to load instances:", e);
- uiState.setStatus("Error loading instances: " + e);
- }
- }
-
- async createInstance(name: string): Promise<Instance | null> {
- try {
- const instance = await invoke<Instance>("create_instance", { name });
- await this.loadInstances();
- uiState.setStatus(`Instance "${name}" created successfully`);
- return instance;
- } catch (e) {
- console.error("Failed to create instance:", e);
- uiState.setStatus("Error creating instance: " + e);
- return null;
- }
- }
-
- async deleteInstance(id: string) {
- try {
- await invoke("delete_instance", { instanceId: id });
- await this.loadInstances();
- // If deleted instance was active, set another as active
- if (this.activeInstanceId === id) {
- if (this.instances.length > 0) {
- await this.setActiveInstance(this.instances[0].id);
- } else {
- this.activeInstanceId = null;
- }
- }
- uiState.setStatus("Instance deleted successfully");
- } catch (e) {
- console.error("Failed to delete instance:", e);
- uiState.setStatus("Error deleting instance: " + e);
- }
- }
-
- async updateInstance(instance: Instance) {
- try {
- await invoke("update_instance", { instance });
- await this.loadInstances();
- uiState.setStatus("Instance updated successfully");
- } catch (e) {
- console.error("Failed to update instance:", e);
- uiState.setStatus("Error updating instance: " + e);
- }
- }
-
- async setActiveInstance(id: string) {
- try {
- await invoke("set_active_instance", { instanceId: id });
- this.activeInstanceId = id;
- uiState.setStatus("Active instance changed");
- } catch (e) {
- console.error("Failed to set active instance:", e);
- uiState.setStatus("Error setting active instance: " + e);
- }
- }
-
- async duplicateInstance(id: string, newName: string): Promise<Instance | null> {
- try {
- const instance = await invoke<Instance>("duplicate_instance", {
- instanceId: id,
- newName,
- });
- await this.loadInstances();
- uiState.setStatus(`Instance duplicated as "${newName}"`);
- return instance;
- } catch (e) {
- console.error("Failed to duplicate instance:", e);
- uiState.setStatus("Error duplicating instance: " + e);
- return null;
- }
- }
-
- async getInstance(id: string): Promise<Instance | null> {
- try {
- return await invoke<Instance>("get_instance", { instanceId: id });
- } catch (e) {
- console.error("Failed to get instance:", e);
- return null;
- }
- }
-}
-
-export const instancesState = new InstancesState();
diff --git a/packages/ui/src/stores/logs-store.ts b/packages/ui/src/stores/logs-store.ts
new file mode 100644
index 0000000..b19f206
--- /dev/null
+++ b/packages/ui/src/stores/logs-store.ts
@@ -0,0 +1,200 @@
+import { listen } from "@tauri-apps/api/event";
+import { create } from "zustand";
+
+export interface LogEntry {
+ id: number;
+ timestamp: string;
+ level: "info" | "warn" | "error" | "debug" | "fatal";
+ source: string;
+ message: string;
+}
+
+// Parse Minecraft/Java log format: [HH:MM:SS] [Thread/LEVEL]: message
+// or: [HH:MM:SS] [Thread/LEVEL] [Source]: message
+const GAME_LOG_REGEX =
+ /^\[[\d:]+\]\s*\[([^\]]+)\/(\w+)\](?:\s*\[([^\]]+)\])?:\s*(.*)$/;
+
+function parseGameLogLevel(levelStr: string): LogEntry["level"] {
+ const upper = levelStr.toUpperCase();
+ if (upper === "INFO") return "info";
+ if (upper === "WARN" || upper === "WARNING") return "warn";
+ if (upper === "ERROR" || upper === "SEVERE") return "error";
+ if (
+ upper === "DEBUG" ||
+ upper === "TRACE" ||
+ upper === "FINE" ||
+ upper === "FINER" ||
+ upper === "FINEST"
+ )
+ return "debug";
+ if (upper === "FATAL") return "fatal";
+ return "info";
+}
+
+interface LogsState {
+ // State
+ logs: LogEntry[];
+ sources: Set<string>;
+ nextId: number;
+ maxLogs: number;
+ initialized: boolean;
+
+ // Actions
+ addLog: (level: LogEntry["level"], source: string, message: string) => void;
+ addGameLog: (rawLine: string, isStderr: boolean) => void;
+ clear: () => void;
+ exportLogs: (filteredLogs: LogEntry[]) => string;
+ init: () => Promise<void>;
+ setLogs: (logs: LogEntry[]) => void;
+ setSources: (sources: Set<string>) => void;
+}
+
+export const useLogsStore = create<LogsState>((set, get) => ({
+ // Initial state
+ logs: [],
+ sources: new Set(["Launcher"]),
+ nextId: 0,
+ maxLogs: 5000,
+ initialized: false,
+
+ // Actions
+ addLog: (level, source, message) => {
+ const { nextId, logs, maxLogs, sources } = get();
+ const now = new Date();
+ const timestamp =
+ now.toLocaleTimeString() +
+ "." +
+ now.getMilliseconds().toString().padStart(3, "0");
+
+ const newLog: LogEntry = {
+ id: nextId,
+ timestamp,
+ level,
+ source,
+ message,
+ };
+
+ const newLogs = [...logs, newLog];
+ const newSources = new Set(sources);
+
+ // Track source
+ if (!newSources.has(source)) {
+ newSources.add(source);
+ }
+
+ // Trim logs if exceeding max
+ const trimmedLogs =
+ newLogs.length > maxLogs ? newLogs.slice(-maxLogs) : newLogs;
+
+ set({
+ logs: trimmedLogs,
+ sources: newSources,
+ nextId: nextId + 1,
+ });
+ },
+
+ addGameLog: (rawLine, isStderr) => {
+ const match = rawLine.match(GAME_LOG_REGEX);
+
+ if (match) {
+ const [, thread, levelStr, extraSource, message] = match;
+ const level = parseGameLogLevel(levelStr);
+ // Use extraSource if available, otherwise use thread name as source hint
+ const source = extraSource || `Game/${thread.split("-")[0]}`;
+ get().addLog(level, source, message);
+ } else {
+ // Fallback: couldn't parse, use stderr as error indicator
+ const level = isStderr ? "error" : "info";
+ get().addLog(level, "Game", rawLine);
+ }
+ },
+
+ clear: () => {
+ set({
+ logs: [],
+ sources: new Set(["Launcher"]),
+ });
+ get().addLog("info", "Launcher", "Logs cleared");
+ },
+
+ exportLogs: (filteredLogs) => {
+ return filteredLogs
+ .map(
+ (l) =>
+ `[${l.timestamp}] [${l.source}/${l.level.toUpperCase()}] ${l.message}`,
+ )
+ .join("\n");
+ },
+
+ init: async () => {
+ const { initialized } = get();
+ if (initialized) return;
+
+ set({ initialized: true });
+
+ // Initial log
+ get().addLog("info", "Launcher", "Logs initialized");
+
+ // General Launcher Logs
+ await listen<string>("launcher-log", (e) => {
+ get().addLog("info", "Launcher", e.payload);
+ });
+
+ // Game Stdout - parse log level
+ await listen<string>("game-stdout", (e) => {
+ get().addGameLog(e.payload, false);
+ });
+
+ // Game Stderr - parse log level, default to error
+ await listen<string>("game-stderr", (e) => {
+ get().addGameLog(e.payload, true);
+ });
+
+ // Download Events (Summarized)
+ await listen("download-start", (e: any) => {
+ get().addLog(
+ "info",
+ "Downloader",
+ `Starting batch download of ${e.payload} files...`,
+ );
+ });
+
+ await listen("download-complete", () => {
+ get().addLog("info", "Downloader", "All downloads completed.");
+ });
+
+ // Listen to file download progress to log finished files
+ await listen<any>("download-progress", (e) => {
+ const p = e.payload;
+ if (p.status === "Finished") {
+ if (p.file.endsWith(".jar")) {
+ get().addLog("info", "Downloader", `Downloaded ${p.file}`);
+ }
+ }
+ });
+
+ // Java Download
+ await listen<any>("java-download-progress", (e) => {
+ const p = e.payload;
+ if (p.status === "Downloading" && p.percentage === 0) {
+ get().addLog(
+ "info",
+ "JavaInstaller",
+ `Downloading Java: ${p.file_name}`,
+ );
+ } else if (p.status === "Completed") {
+ get().addLog("info", "JavaInstaller", `Java installed: ${p.file_name}`);
+ } else if (p.status === "Error") {
+ get().addLog("error", "JavaInstaller", `Java download error`);
+ }
+ });
+ },
+
+ setLogs: (logs) => {
+ set({ logs });
+ },
+
+ setSources: (sources) => {
+ set({ sources });
+ },
+}));
diff --git a/packages/ui/src/stores/logs.svelte.ts b/packages/ui/src/stores/logs.svelte.ts
deleted file mode 100644
index c9d4acc..0000000
--- a/packages/ui/src/stores/logs.svelte.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { listen } from "@tauri-apps/api/event";
-
-export interface LogEntry {
- id: number;
- timestamp: string;
- level: "info" | "warn" | "error" | "debug" | "fatal";
- source: string;
- message: string;
-}
-
-// Parse Minecraft/Java log format: [HH:MM:SS] [Thread/LEVEL]: message
-// or: [HH:MM:SS] [Thread/LEVEL] [Source]: message
-const GAME_LOG_REGEX = /^\[[\d:]+\]\s*\[([^\]]+)\/(\w+)\](?:\s*\[([^\]]+)\])?:\s*(.*)$/;
-
-function parseGameLogLevel(levelStr: string): LogEntry["level"] {
- const upper = levelStr.toUpperCase();
- if (upper === "INFO") return "info";
- if (upper === "WARN" || upper === "WARNING") return "warn";
- if (upper === "ERROR" || upper === "SEVERE") return "error";
- if (
- upper === "DEBUG" ||
- upper === "TRACE" ||
- upper === "FINE" ||
- upper === "FINER" ||
- upper === "FINEST"
- )
- return "debug";
- if (upper === "FATAL") return "fatal";
- return "info";
-}
-
-export class LogsState {
- logs = $state<LogEntry[]>([]);
- private nextId = 0;
- private maxLogs = 5000;
-
- // Track all unique sources for filtering
- sources = $state<Set<string>>(new Set(["Launcher"]));
-
- constructor() {
- this.addLog("info", "Launcher", "Logs initialized");
- }
-
- addLog(level: LogEntry["level"], source: string, message: string) {
- const now = new Date();
- const timestamp =
- now.toLocaleTimeString() + "." + now.getMilliseconds().toString().padStart(3, "0");
-
- this.logs.push({
- id: this.nextId++,
- timestamp,
- level,
- source,
- message,
- });
-
- // Track source
- if (!this.sources.has(source)) {
- this.sources = new Set([...this.sources, source]);
- }
-
- if (this.logs.length > this.maxLogs) {
- this.logs.shift();
- }
- }
-
- // Parse game output and extract level/source
- addGameLog(rawLine: string, isStderr: boolean) {
- const match = rawLine.match(GAME_LOG_REGEX);
-
- if (match) {
- const [, thread, levelStr, extraSource, message] = match;
- const level = parseGameLogLevel(levelStr);
- // Use extraSource if available, otherwise use thread name as source hint
- const source = extraSource || `Game/${thread.split("-")[0]}`;
- this.addLog(level, source, message);
- } else {
- // Fallback: couldn't parse, use stderr as error indicator
- const level = isStderr ? "error" : "info";
- this.addLog(level, "Game", rawLine);
- }
- }
-
- clear() {
- this.logs = [];
- this.sources = new Set(["Launcher"]);
- this.addLog("info", "Launcher", "Logs cleared");
- }
-
- // Export with filter support
- exportLogs(filteredLogs: LogEntry[]): string {
- return filteredLogs
- .map((l) => `[${l.timestamp}] [${l.source}/${l.level.toUpperCase()}] ${l.message}`)
- .join("\n");
- }
-
- private initialized = false;
-
- async init() {
- if (this.initialized) return;
- this.initialized = true;
-
- // General Launcher Logs
- await listen<string>("launcher-log", (e) => {
- this.addLog("info", "Launcher", e.payload);
- });
-
- // Game Stdout - parse log level
- await listen<string>("game-stdout", (e) => {
- this.addGameLog(e.payload, false);
- });
-
- // Game Stderr - parse log level, default to error
- await listen<string>("game-stderr", (e) => {
- this.addGameLog(e.payload, true);
- });
-
- // Download Events (Summarized)
- await listen("download-start", (e) => {
- this.addLog("info", "Downloader", `Starting batch download of ${e.payload} files...`);
- });
-
- await listen("download-complete", () => {
- this.addLog("info", "Downloader", "All downloads completed.");
- });
-
- // Listen to file download progress to log finished files
- await listen<any>("download-progress", (e) => {
- const p = e.payload;
- if (p.status === "Finished") {
- if (p.file.endsWith(".jar")) {
- this.addLog("info", "Downloader", `Downloaded ${p.file}`);
- }
- }
- });
-
- // Java Download
- await listen<any>("java-download-progress", (e) => {
- const p = e.payload;
- if (p.status === "Downloading" && p.percentage === 0) {
- this.addLog("info", "JavaInstaller", `Downloading Java: ${p.file_name}`);
- } else if (p.status === "Completed") {
- this.addLog("info", "JavaInstaller", `Java installed: ${p.file_name}`);
- } else if (p.status === "Error") {
- this.addLog("error", "JavaInstaller", `Java download error`);
- }
- });
- }
-}
-
-export const logsState = new LogsState();
diff --git a/packages/ui/src/stores/releases-store.ts b/packages/ui/src/stores/releases-store.ts
new file mode 100644
index 0000000..56afa08
--- /dev/null
+++ b/packages/ui/src/stores/releases-store.ts
@@ -0,0 +1,63 @@
+import { invoke } from "@tauri-apps/api/core";
+import { create } from "zustand";
+import type { GithubRelease } from "@/types/bindings/core";
+
+interface ReleasesState {
+ // State
+ releases: GithubRelease[];
+ isLoading: boolean;
+ isLoaded: boolean;
+ error: string | null;
+
+ // Actions
+ loadReleases: () => Promise<void>;
+ setReleases: (releases: GithubRelease[]) => void;
+ setIsLoading: (isLoading: boolean) => void;
+ setIsLoaded: (isLoaded: boolean) => void;
+ setError: (error: string | null) => void;
+}
+
+export const useReleasesStore = create<ReleasesState>((set, get) => ({
+ // Initial state
+ releases: [],
+ isLoading: false,
+ isLoaded: false,
+ error: null,
+
+ // Actions
+ loadReleases: async () => {
+ const { isLoaded, isLoading } = get();
+
+ // If already loaded or currently loading, skip to prevent duplicate requests
+ if (isLoaded || isLoading) return;
+
+ set({ isLoading: true, error: null });
+
+ try {
+ const releases = await invoke<GithubRelease[]>("get_github_releases");
+ set({ releases, isLoaded: true });
+ } catch (e) {
+ const error = e instanceof Error ? e.message : String(e);
+ console.error("Failed to load releases:", e);
+ set({ error });
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ setReleases: (releases) => {
+ set({ releases });
+ },
+
+ setIsLoading: (isLoading) => {
+ set({ isLoading });
+ },
+
+ setIsLoaded: (isLoaded) => {
+ set({ isLoaded });
+ },
+
+ setError: (error) => {
+ set({ error });
+ },
+}));
diff --git a/packages/ui/src/stores/releases.svelte.ts b/packages/ui/src/stores/releases.svelte.ts
deleted file mode 100644
index c858abb..0000000
--- a/packages/ui/src/stores/releases.svelte.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-
-export interface GithubRelease {
- tag_name: string;
- name: string;
- published_at: string;
- body: string;
- html_url: string;
-}
-
-export class ReleasesState {
- releases = $state<GithubRelease[]>([]);
- isLoading = $state(false);
- isLoaded = $state(false);
- error = $state<string | null>(null);
-
- async loadReleases() {
- // If already loaded or currently loading, skip to prevent duplicate requests
- if (this.isLoaded || this.isLoading) return;
-
- this.isLoading = true;
- this.error = null;
-
- try {
- this.releases = await invoke<GithubRelease[]>("get_github_releases");
- this.isLoaded = true;
- } catch (e) {
- console.error("Failed to load releases:", e);
- this.error = String(e);
- } finally {
- this.isLoading = false;
- }
- }
-}
-
-export const releasesState = new ReleasesState();
diff --git a/packages/ui/src/stores/settings-store.ts b/packages/ui/src/stores/settings-store.ts
new file mode 100644
index 0000000..0bfc1e1
--- /dev/null
+++ b/packages/ui/src/stores/settings-store.ts
@@ -0,0 +1,568 @@
+import { convertFileSrc, invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { toast } from "sonner";
+import { create } from "zustand";
+import { downloadAdoptiumJava } from "@/client";
+import type { ModelInfo } from "../types/bindings/assistant";
+import type { LauncherConfig } from "../types/bindings/config";
+import type {
+ JavaDownloadProgress,
+ PendingJavaDownload,
+} from "../types/bindings/downloader";
+import type {
+ JavaCatalog,
+ JavaInstallation,
+ JavaReleaseInfo,
+} from "../types/bindings/java";
+
+type JavaDownloadSource = "adoptium" | "mojang" | "azul";
+
+/**
+ * State shape for settings store.
+ *
+ * Note: Uses camelCase naming to match ts-rs generated bindings (which now use
+ * `serde(rename_all = "camelCase")`). When reading raw binding objects from
+ * invoke, convert/mapping should be applied where necessary.
+ */
+interface SettingsState {
+ // State
+ settings: LauncherConfig;
+ javaInstallations: JavaInstallation[];
+ isDetectingJava: boolean;
+ showJavaDownloadModal: boolean;
+ selectedDownloadSource: JavaDownloadSource;
+ javaCatalog: JavaCatalog | null;
+ isLoadingCatalog: boolean;
+ catalogError: string;
+ selectedMajorVersion: number | null;
+ selectedImageType: "jre" | "jdk";
+ showOnlyRecommended: boolean;
+ searchQuery: string;
+ isDownloadingJava: boolean;
+ downloadProgress: JavaDownloadProgress | null;
+ javaDownloadStatus: string;
+ pendingDownloads: PendingJavaDownload[];
+ ollamaModels: ModelInfo[];
+ openaiModels: ModelInfo[];
+ isLoadingOllamaModels: boolean;
+ isLoadingOpenaiModels: boolean;
+ ollamaModelsError: string;
+ openaiModelsError: string;
+ showConfigEditor: boolean;
+ rawConfigContent: string;
+ configFilePath: string;
+ configEditorError: string;
+
+ // Computed / derived
+ backgroundUrl: string | undefined;
+ filteredReleases: JavaReleaseInfo[];
+ availableMajorVersions: number[];
+ installStatus: (
+ version: number,
+ imageType: string,
+ ) => "installed" | "downloading" | "available";
+ selectedRelease: JavaReleaseInfo | null;
+ currentModelOptions: Array<{
+ value: string;
+ label: string;
+ details?: string;
+ }>;
+
+ // Actions
+ loadSettings: () => Promise<void>;
+ saveSettings: () => Promise<void>;
+ // compatibility helper to mirror the older set({ key: value }) usage
+ set: (patch: Partial<Record<string, unknown>>) => void;
+
+ detectJava: () => Promise<void>;
+ selectJava: (path: string) => void;
+
+ openJavaDownloadModal: () => Promise<void>;
+ closeJavaDownloadModal: () => void;
+ loadJavaCatalog: (forceRefresh: boolean) => Promise<void>;
+ refreshCatalog: () => Promise<void>;
+ loadPendingDownloads: () => Promise<void>;
+ selectMajorVersion: (version: number) => void;
+ downloadJava: () => Promise<void>;
+ cancelDownload: () => Promise<void>;
+ resumeDownloads: () => Promise<void>;
+
+ openConfigEditor: () => Promise<void>;
+ closeConfigEditor: () => void;
+ saveRawConfig: () => Promise<void>;
+
+ loadOllamaModels: () => Promise<void>;
+ loadOpenaiModels: () => Promise<void>;
+
+ setSetting: <K extends keyof LauncherConfig>(
+ key: K,
+ value: LauncherConfig[K],
+ ) => void;
+ setAssistantSetting: <K extends keyof LauncherConfig["assistant"]>(
+ key: K,
+ value: LauncherConfig["assistant"][K],
+ ) => void;
+ setFeatureFlag: <K extends keyof LauncherConfig["featureFlags"]>(
+ key: K,
+ value: LauncherConfig["featureFlags"][K],
+ ) => void;
+
+ // Private
+ progressUnlisten: UnlistenFn | null;
+}
+
+/**
+ * Default settings (camelCase) — lightweight defaults used until `get_settings`
+ * returns real values.
+ */
+const defaultSettings: LauncherConfig = {
+ minMemory: 1024,
+ maxMemory: 2048,
+ javaPath: "java",
+ width: 854,
+ height: 480,
+ downloadThreads: 32,
+ enableGpuAcceleration: false,
+ enableVisualEffects: true,
+ activeEffect: "constellation",
+ theme: "dark",
+ customBackgroundPath: null,
+ logUploadService: "paste.rs",
+ pastebinApiKey: null,
+ assistant: {
+ enabled: true,
+ llmProvider: "ollama",
+ ollamaEndpoint: "http://localhost:11434",
+ ollamaModel: "llama3",
+ openaiApiKey: null,
+ openaiEndpoint: "https://api.openai.com/v1",
+ openaiModel: "gpt-3.5-turbo",
+ systemPrompt:
+ "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.",
+ responseLanguage: "auto",
+ ttsEnabled: false,
+ ttsProvider: "disabled",
+ },
+ useSharedCaches: false,
+ keepLegacyPerInstanceStorage: true,
+ featureFlags: {
+ demoUser: false,
+ quickPlayEnabled: false,
+ quickPlayPath: null,
+ quickPlaySingleplayer: true,
+ quickPlayMultiplayerServer: null,
+ },
+};
+
+export const useSettingsStore = create<SettingsState>((set, get) => ({
+ // initial state
+ settings: defaultSettings,
+ javaInstallations: [],
+ isDetectingJava: false,
+ showJavaDownloadModal: false,
+ selectedDownloadSource: "adoptium",
+ javaCatalog: null,
+ isLoadingCatalog: false,
+ catalogError: "",
+ selectedMajorVersion: null,
+ selectedImageType: "jre",
+ showOnlyRecommended: true,
+ searchQuery: "",
+ isDownloadingJava: false,
+ downloadProgress: null,
+ javaDownloadStatus: "",
+ pendingDownloads: [],
+ ollamaModels: [],
+ openaiModels: [],
+ isLoadingOllamaModels: false,
+ isLoadingOpenaiModels: false,
+ ollamaModelsError: "",
+ openaiModelsError: "",
+ showConfigEditor: false,
+ rawConfigContent: "",
+ configFilePath: "",
+ configEditorError: "",
+ progressUnlisten: null,
+
+ // derived getters
+ get backgroundUrl() {
+ const { settings } = get();
+ if (settings.customBackgroundPath) {
+ return convertFileSrc(settings.customBackgroundPath);
+ }
+ return undefined;
+ },
+
+ get filteredReleases() {
+ const {
+ javaCatalog,
+ selectedMajorVersion,
+ selectedImageType,
+ showOnlyRecommended,
+ searchQuery,
+ } = get();
+
+ if (!javaCatalog) return [];
+
+ let releases = javaCatalog.releases;
+
+ if (selectedMajorVersion !== null) {
+ releases = releases.filter(
+ (r) => r.majorVersion === selectedMajorVersion,
+ );
+ }
+
+ releases = releases.filter((r) => r.imageType === selectedImageType);
+
+ if (showOnlyRecommended) {
+ releases = releases.filter((r) => r.isLts);
+ }
+
+ if (searchQuery.trim() !== "") {
+ const q = searchQuery.toLowerCase();
+ releases = releases.filter(
+ (r) =>
+ r.version.toLowerCase().includes(q) ||
+ (r.releaseName ?? "").toLowerCase().includes(q),
+ );
+ }
+
+ // sort newest-first by parsed version number
+ return releases.sort((a, b) => {
+ const aVer = parseFloat(a.version.split("-")[0]);
+ const bVer = parseFloat(b.version.split("-")[0]);
+ return bVer - aVer;
+ });
+ },
+
+ get availableMajorVersions() {
+ return get().javaCatalog?.availableMajorVersions || [];
+ },
+
+ installStatus: (version: number, imageType: string) => {
+ const {
+ javaInstallations,
+ pendingDownloads,
+ isDownloadingJava,
+ downloadProgress,
+ } = get();
+
+ const installed = javaInstallations.some(
+ (inst) => parseInt(inst.version.split(".")[0], 10) === version,
+ );
+ if (installed) return "installed";
+
+ if (
+ isDownloadingJava &&
+ downloadProgress?.fileName?.includes(`${version}`)
+ ) {
+ return "downloading";
+ }
+
+ const pending = pendingDownloads.some(
+ (d) => d.majorVersion === version && d.imageType === imageType,
+ );
+ if (pending) return "downloading";
+
+ return "available";
+ },
+
+ get selectedRelease() {
+ const { javaCatalog, selectedMajorVersion, selectedImageType } = get();
+ if (!javaCatalog || selectedMajorVersion === null) return null;
+ return (
+ javaCatalog.releases.find(
+ (r) =>
+ r.majorVersion === selectedMajorVersion &&
+ r.imageType === selectedImageType,
+ ) || null
+ );
+ },
+
+ get currentModelOptions() {
+ const { settings, ollamaModels, openaiModels } = get();
+ const provider = settings.assistant.llmProvider;
+ if (provider === "ollama") {
+ return ollamaModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.details || m.size || "",
+ }));
+ } else {
+ return openaiModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.details || "",
+ }));
+ }
+ },
+
+ // actions
+ loadSettings: async () => {
+ try {
+ const result = await invoke<LauncherConfig>("get_settings");
+ // result already uses camelCase fields from bindings
+ set({ settings: result });
+
+ // enforce dark theme at app-level if necessary
+ if (result.theme !== "dark") {
+ const updated = { ...result, theme: "dark" } as LauncherConfig;
+ set({ settings: updated });
+ await invoke("save_settings", { config: updated });
+ }
+
+ // ensure customBackgroundPath is undefined rather than null for reactiveness
+ if (!result.customBackgroundPath) {
+ set((s) => ({
+ settings: { ...s.settings, customBackgroundPath: null },
+ }));
+ }
+ } catch (e) {
+ console.error("Failed to load settings:", e);
+ }
+ },
+
+ saveSettings: async () => {
+ try {
+ const { settings } = get();
+
+ // Clean up empty strings to null where appropriate
+ if ((settings.customBackgroundPath ?? "") === "") {
+ set((state) => ({
+ settings: { ...state.settings, customBackgroundPath: null },
+ }));
+ }
+
+ await invoke("save_settings", { config: settings });
+ toast.success("Settings saved!");
+ } catch (e) {
+ console.error("Failed to save settings:", e);
+ toast.error(`Error saving settings: ${String(e)}`);
+ }
+ },
+
+ set: (patch: Partial<Record<string, unknown>>) => {
+ set(patch);
+ },
+
+ detectJava: async () => {
+ set({ isDetectingJava: true });
+ try {
+ const installs = await invoke<JavaInstallation[]>("detect_java");
+ set({ javaInstallations: installs });
+ if (installs.length === 0) toast.info("No Java installations found");
+ else toast.success(`Found ${installs.length} Java installation(s)`);
+ } catch (e) {
+ console.error("Failed to detect Java:", e);
+ toast.error(`Error detecting Java: ${String(e)}`);
+ } finally {
+ set({ isDetectingJava: false });
+ }
+ },
+
+ selectJava: (path: string) => {
+ set((s) => ({ settings: { ...s.settings, javaPath: path } }));
+ },
+
+ openJavaDownloadModal: async () => {
+ set({
+ showJavaDownloadModal: true,
+ javaDownloadStatus: "",
+ catalogError: "",
+ downloadProgress: null,
+ });
+
+ // attach event listener for download progress
+ const state = get();
+ if (state.progressUnlisten) {
+ state.progressUnlisten();
+ }
+
+ const unlisten = await listen<JavaDownloadProgress>(
+ "java-download-progress",
+ (event) => {
+ set({ downloadProgress: event.payload });
+ },
+ );
+
+ set({ progressUnlisten: unlisten });
+
+ // load catalog and pending downloads
+ await get().loadJavaCatalog(false);
+ await get().loadPendingDownloads();
+ },
+
+ closeJavaDownloadModal: () => {
+ const { isDownloadingJava, progressUnlisten } = get();
+
+ if (!isDownloadingJava) {
+ set({ showJavaDownloadModal: false });
+ if (progressUnlisten) {
+ try {
+ progressUnlisten();
+ } catch {
+ // ignore
+ }
+ set({ progressUnlisten: null });
+ }
+ }
+ },
+
+ loadJavaCatalog: async (forceRefresh: boolean) => {
+ set({ isLoadingCatalog: true, catalogError: "" });
+ try {
+ const cmd = forceRefresh ? "refresh_java_catalog" : "get_java_catalog";
+ const result = await invoke<JavaCatalog>(cmd);
+ set({ javaCatalog: result, isLoadingCatalog: false });
+ } catch (e) {
+ console.error("Failed to load Java catalog:", e);
+ set({ catalogError: String(e), isLoadingCatalog: false });
+ }
+ },
+
+ refreshCatalog: async () => {
+ await get().loadJavaCatalog(true);
+ },
+
+ loadPendingDownloads: async () => {
+ try {
+ const pending = await invoke<PendingJavaDownload[]>(
+ "get_pending_java_downloads",
+ );
+ set({ pendingDownloads: pending });
+ } catch (e) {
+ console.error("Failed to load pending downloads:", e);
+ }
+ },
+
+ selectMajorVersion: (version: number) => {
+ set({ selectedMajorVersion: version });
+ },
+
+ downloadJava: async () => {
+ const { selectedMajorVersion, selectedImageType, selectedDownloadSource } =
+ get();
+ if (!selectedMajorVersion) return;
+ set({ isDownloadingJava: true, javaDownloadStatus: "Starting..." });
+ try {
+ const result = await downloadAdoptiumJava(
+ selectedMajorVersion,
+ selectedImageType,
+ selectedDownloadSource,
+ );
+ set({
+ javaDownloadStatus: `Java ${selectedMajorVersion} download started: ${result.path}`,
+ });
+ toast.success("Download started");
+ } catch (e) {
+ console.error("Failed to download Java:", e);
+ toast.error(`Failed to start Java download: ${String(e)}`);
+ } finally {
+ set({ isDownloadingJava: false });
+ }
+ },
+
+ cancelDownload: async () => {
+ try {
+ await invoke("cancel_java_download");
+ toast.success("Cancelled Java download");
+ set({ isDownloadingJava: false, javaDownloadStatus: "" });
+ } catch (e) {
+ console.error("Failed to cancel download:", e);
+ toast.error(`Failed to cancel download: ${String(e)}`);
+ }
+ },
+
+ resumeDownloads: async () => {
+ try {
+ const installed = await invoke<boolean>("resume_java_downloads");
+ if (installed) toast.success("Resumed Java downloads");
+ else toast.info("No downloads to resume");
+ } catch (e) {
+ console.error("Failed to resume downloads:", e);
+ toast.error(`Failed to resume downloads: ${String(e)}`);
+ }
+ },
+
+ openConfigEditor: async () => {
+ try {
+ const path = await invoke<string>("get_config_path");
+ const content = await invoke<string>("read_config_raw");
+ set({
+ configFilePath: path,
+ rawConfigContent: content,
+ showConfigEditor: true,
+ });
+ } catch (e) {
+ console.error("Failed to open config editor:", e);
+ set({ configEditorError: String(e) });
+ }
+ },
+
+ closeConfigEditor: () => {
+ set({
+ showConfigEditor: false,
+ rawConfigContent: "",
+ configFilePath: "",
+ configEditorError: "",
+ });
+ },
+
+ saveRawConfig: async () => {
+ try {
+ await invoke("write_config_raw", { content: get().rawConfigContent });
+ toast.success("Config saved");
+ set({ showConfigEditor: false });
+ } catch (e) {
+ console.error("Failed to save config:", e);
+ set({ configEditorError: String(e) });
+ toast.error(`Failed to save config: ${String(e)}`);
+ }
+ },
+
+ loadOllamaModels: async () => {
+ set({ isLoadingOllamaModels: true, ollamaModelsError: "" });
+ try {
+ const models = await invoke<ModelInfo[]>("get_ollama_models");
+ set({ ollamaModels: models, isLoadingOllamaModels: false });
+ } catch (e) {
+ console.error("Failed to load Ollama models:", e);
+ set({ isLoadingOllamaModels: false, ollamaModelsError: String(e) });
+ }
+ },
+
+ loadOpenaiModels: async () => {
+ set({ isLoadingOpenaiModels: true, openaiModelsError: "" });
+ try {
+ const models = await invoke<ModelInfo[]>("get_openai_models");
+ set({ openaiModels: models, isLoadingOpenaiModels: false });
+ } catch (e) {
+ console.error("Failed to load OpenAI models:", e);
+ set({ isLoadingOpenaiModels: false, openaiModelsError: String(e) });
+ }
+ },
+
+ setSetting: (key, value) => {
+ set((s) => ({
+ settings: { ...s.settings, [key]: value } as unknown as LauncherConfig,
+ }));
+ },
+
+ setAssistantSetting: (key, value) => {
+ set((s) => ({
+ settings: {
+ ...s.settings,
+ assistant: { ...s.settings.assistant, [key]: value },
+ } as LauncherConfig,
+ }));
+ },
+
+ setFeatureFlag: (key, value) => {
+ set((s) => ({
+ settings: {
+ ...s.settings,
+ featureFlags: { ...s.settings.featureFlags, [key]: value },
+ } as LauncherConfig,
+ }));
+ },
+}));
diff --git a/packages/ui/src/stores/settings.svelte.ts b/packages/ui/src/stores/settings.svelte.ts
deleted file mode 100644
index 5d20050..0000000
--- a/packages/ui/src/stores/settings.svelte.ts
+++ /dev/null
@@ -1,570 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import { convertFileSrc } from "@tauri-apps/api/core";
-import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-import type {
- JavaCatalog,
- JavaDownloadProgress,
- JavaDownloadSource,
- JavaInstallation,
- JavaReleaseInfo,
- LauncherConfig,
- ModelInfo,
- PendingJavaDownload,
-} from "../types";
-import { uiState } from "./ui.svelte";
-
-export class SettingsState {
- settings = $state<LauncherConfig>({
- min_memory: 1024,
- max_memory: 2048,
- java_path: "java",
- width: 854,
- height: 480,
- download_threads: 32,
- enable_gpu_acceleration: false,
- enable_visual_effects: true,
- active_effect: "constellation",
- theme: "dark",
- custom_background_path: undefined,
- log_upload_service: "paste.rs",
- pastebin_api_key: undefined,
- assistant: {
- enabled: true,
- llm_provider: "ollama",
- ollama_endpoint: "http://localhost:11434",
- ollama_model: "llama3",
- openai_api_key: undefined,
- openai_endpoint: "https://api.openai.com/v1",
- openai_model: "gpt-3.5-turbo",
- system_prompt:
- "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.",
- response_language: "auto",
- tts_enabled: false,
- tts_provider: "disabled",
- },
- use_shared_caches: false,
- keep_legacy_per_instance_storage: true,
- feature_flags: {
- demo_user: false,
- quick_play_enabled: false,
- quick_play_path: undefined,
- quick_play_singleplayer: true,
- quick_play_multiplayer_server: undefined,
- },
- });
-
- // Convert background path to proper asset URL
- get backgroundUrl(): string | undefined {
- if (this.settings.custom_background_path) {
- return convertFileSrc(this.settings.custom_background_path);
- }
- return undefined;
- }
- javaInstallations = $state<JavaInstallation[]>([]);
- isDetectingJava = $state(false);
-
- // Java download modal state
- showJavaDownloadModal = $state(false);
- selectedDownloadSource = $state<JavaDownloadSource>("adoptium");
-
- // Java catalog state
- javaCatalog = $state<JavaCatalog | null>(null);
- isLoadingCatalog = $state(false);
- catalogError = $state("");
-
- // Version selection state
- selectedMajorVersion = $state<number | null>(null);
- selectedImageType = $state<"jre" | "jdk">("jre");
- showOnlyRecommended = $state(true);
- searchQuery = $state("");
-
- // Download progress state
- isDownloadingJava = $state(false);
- downloadProgress = $state<JavaDownloadProgress | null>(null);
- javaDownloadStatus = $state("");
-
- // Pending downloads
- pendingDownloads = $state<PendingJavaDownload[]>([]);
-
- // AI Model lists
- ollamaModels = $state<ModelInfo[]>([]);
- openaiModels = $state<ModelInfo[]>([]);
- isLoadingOllamaModels = $state(false);
- isLoadingOpenaiModels = $state(false);
- ollamaModelsError = $state("");
- openaiModelsError = $state("");
-
- // Config Editor state
- showConfigEditor = $state(false);
- rawConfigContent = $state("");
- configFilePath = $state("");
- configEditorError = $state("");
-
- // Event listener cleanup
- private progressUnlisten: UnlistenFn | null = null;
-
- async openConfigEditor() {
- this.configEditorError = "";
- try {
- const path = await invoke<string>("get_config_path");
- const content = await invoke<string>("read_raw_config");
- this.configFilePath = path;
- this.rawConfigContent = content;
- this.showConfigEditor = true;
- } catch (e) {
- console.error("Failed to open config editor:", e);
- uiState.setStatus(`Failed to open config: ${e}`);
- }
- }
-
- async saveRawConfig(content: string, closeAfterSave = true) {
- try {
- await invoke("save_raw_config", { content });
- // Reload settings to ensure UI is in sync
- await this.loadSettings();
- if (closeAfterSave) {
- this.showConfigEditor = false;
- }
- uiState.setStatus("Configuration saved successfully!");
- } catch (e) {
- console.error("Failed to save config:", e);
- this.configEditorError = String(e);
- }
- }
-
- closeConfigEditor() {
- this.showConfigEditor = false;
- this.rawConfigContent = "";
- this.configEditorError = "";
- }
-
- // Computed: filtered releases based on selection
- get filteredReleases(): JavaReleaseInfo[] {
- if (!this.javaCatalog) return [];
-
- let releases = this.javaCatalog.releases;
-
- // Filter by major version if selected
- if (this.selectedMajorVersion !== null) {
- releases = releases.filter((r) => r.major_version === this.selectedMajorVersion);
- }
-
- // Filter by image type
- releases = releases.filter((r) => r.image_type === this.selectedImageType);
-
- // Filter by recommended (LTS) versions
- if (this.showOnlyRecommended) {
- releases = releases.filter((r) => r.is_lts);
- }
-
- // Filter by search query
- if (this.searchQuery.trim()) {
- const query = this.searchQuery.toLowerCase();
- releases = releases.filter(
- (r) =>
- r.release_name.toLowerCase().includes(query) ||
- r.version.toLowerCase().includes(query) ||
- r.major_version.toString().includes(query),
- );
- }
-
- return releases;
- }
-
- // Computed: available major versions for display
- get availableMajorVersions(): number[] {
- if (!this.javaCatalog) return [];
- let versions = [...this.javaCatalog.available_major_versions];
-
- // Filter by LTS if showOnlyRecommended is enabled
- if (this.showOnlyRecommended) {
- versions = versions.filter((v) => this.javaCatalog!.lts_versions.includes(v));
- }
-
- // Sort descending (newest first)
- return versions.sort((a, b) => b - a);
- }
-
- // Get installation status for a release: 'installed' | 'download'
- getInstallStatus(release: JavaReleaseInfo): "installed" | "download" {
- // Find installed Java that matches the major version and image type (by path pattern)
- const matchingInstallations = this.javaInstallations.filter((inst) => {
- // Check if this is a DropOut-managed Java (path contains temurin-XX-jre/jdk pattern)
- const pathLower = inst.path.toLowerCase();
- const pattern = `temurin-${release.major_version}-${release.image_type}`;
- return pathLower.includes(pattern);
- });
-
- // If any matching installation exists, it's installed
- return matchingInstallations.length > 0 ? "installed" : "download";
- }
-
- // Computed: selected release details
- get selectedRelease(): JavaReleaseInfo | null {
- if (!this.javaCatalog || this.selectedMajorVersion === null) return null;
- return (
- this.javaCatalog.releases.find(
- (r) =>
- r.major_version === this.selectedMajorVersion && r.image_type === this.selectedImageType,
- ) || null
- );
- }
-
- async loadSettings() {
- try {
- const result = await invoke<LauncherConfig>("get_settings");
- this.settings = result;
- // Force dark mode
- if (this.settings.theme !== "dark") {
- this.settings.theme = "dark";
- this.saveSettings();
- }
- // Ensure custom_background_path is reactive
- if (!this.settings.custom_background_path) {
- this.settings.custom_background_path = undefined;
- }
- } catch (e) {
- console.error("Failed to load settings:", e);
- }
- }
-
- async saveSettings() {
- try {
- // Ensure we clean up any invalid paths before saving
- if (this.settings.custom_background_path === "") {
- this.settings.custom_background_path = undefined;
- }
-
- await invoke("save_settings", { config: this.settings });
- uiState.setStatus("Settings saved!");
- } catch (e) {
- console.error("Failed to save settings:", e);
- uiState.setStatus("Error saving settings: " + e);
- }
- }
-
- async detectJava() {
- this.isDetectingJava = true;
- try {
- this.javaInstallations = await invoke("detect_java");
- if (this.javaInstallations.length === 0) {
- uiState.setStatus("No Java installations found");
- } else {
- uiState.setStatus(`Found ${this.javaInstallations.length} Java installation(s)`);
- }
- } catch (e) {
- console.error("Failed to detect Java:", e);
- uiState.setStatus("Error detecting Java: " + e);
- } finally {
- this.isDetectingJava = false;
- }
- }
-
- selectJava(path: string) {
- this.settings.java_path = path;
- }
-
- async openJavaDownloadModal() {
- this.showJavaDownloadModal = true;
- this.javaDownloadStatus = "";
- this.catalogError = "";
- this.downloadProgress = null;
-
- // Setup progress event listener
- await this.setupProgressListener();
-
- // Load catalog
- await this.loadJavaCatalog(false);
-
- // Check for pending downloads
- await this.loadPendingDownloads();
- }
-
- async closeJavaDownloadModal() {
- if (!this.isDownloadingJava) {
- this.showJavaDownloadModal = false;
- // Cleanup listener
- if (this.progressUnlisten) {
- this.progressUnlisten();
- this.progressUnlisten = null;
- }
- }
- }
-
- private async setupProgressListener() {
- if (this.progressUnlisten) {
- this.progressUnlisten();
- }
-
- this.progressUnlisten = await listen<JavaDownloadProgress>(
- "java-download-progress",
- (event) => {
- this.downloadProgress = event.payload;
- this.javaDownloadStatus = event.payload.status;
-
- if (event.payload.status === "Completed") {
- this.isDownloadingJava = false;
- setTimeout(async () => {
- await this.detectJava();
- uiState.setStatus(`Java installed successfully!`);
- }, 500);
- } else if (event.payload.status === "Error") {
- this.isDownloadingJava = false;
- }
- },
- );
- }
-
- async loadJavaCatalog(forceRefresh: boolean) {
- this.isLoadingCatalog = true;
- this.catalogError = "";
-
- try {
- const command = forceRefresh ? "refresh_java_catalog" : "fetch_java_catalog";
- this.javaCatalog = await invoke<JavaCatalog>(command);
-
- // Auto-select first LTS version
- if (this.selectedMajorVersion === null && this.javaCatalog.lts_versions.length > 0) {
- // Select most recent LTS (21 or highest)
- const ltsVersions = [...this.javaCatalog.lts_versions].sort((a, b) => b - a);
- this.selectedMajorVersion = ltsVersions[0];
- }
- } catch (e) {
- console.error("Failed to load Java catalog:", e);
- this.catalogError = `Failed to load Java catalog: ${e}`;
- } finally {
- this.isLoadingCatalog = false;
- }
- }
-
- async refreshCatalog() {
- await this.loadJavaCatalog(true);
- uiState.setStatus("Java catalog refreshed");
- }
-
- async loadPendingDownloads() {
- try {
- this.pendingDownloads = await invoke<PendingJavaDownload[]>("get_pending_java_downloads");
- } catch (e) {
- console.error("Failed to load pending downloads:", e);
- }
- }
-
- selectMajorVersion(version: number) {
- this.selectedMajorVersion = version;
- }
-
- async downloadJava() {
- if (!this.selectedRelease || !this.selectedRelease.is_available) {
- uiState.setStatus("Selected Java version is not available for this platform");
- return;
- }
-
- this.isDownloadingJava = true;
- this.javaDownloadStatus = "Starting download...";
- this.downloadProgress = null;
-
- try {
- const result: JavaInstallation = await invoke("download_adoptium_java", {
- majorVersion: this.selectedMajorVersion,
- imageType: this.selectedImageType,
- customPath: null,
- });
-
- this.settings.java_path = result.path;
- await this.detectJava();
-
- setTimeout(() => {
- this.showJavaDownloadModal = false;
- uiState.setStatus(`Java ${this.selectedMajorVersion} is ready to use!`);
- }, 1500);
- } catch (e) {
- console.error("Failed to download Java:", e);
- this.javaDownloadStatus = `Download failed: ${e}`;
- } finally {
- this.isDownloadingJava = false;
- }
- }
-
- async cancelDownload() {
- try {
- await invoke("cancel_java_download");
- this.isDownloadingJava = false;
- this.javaDownloadStatus = "Download cancelled";
- this.downloadProgress = null;
- await this.loadPendingDownloads();
- } catch (e) {
- console.error("Failed to cancel download:", e);
- }
- }
-
- async resumeDownloads() {
- if (this.pendingDownloads.length === 0) return;
-
- this.isDownloadingJava = true;
- this.javaDownloadStatus = "Resuming download...";
-
- try {
- const installed = await invoke<JavaInstallation[]>("resume_java_downloads");
- if (installed.length > 0) {
- this.settings.java_path = installed[0].path;
- await this.detectJava();
- uiState.setStatus(`Resumed and installed ${installed.length} Java version(s)`);
- }
- await this.loadPendingDownloads();
- } catch (e) {
- console.error("Failed to resume downloads:", e);
- this.javaDownloadStatus = `Resume failed: ${e}`;
- } finally {
- this.isDownloadingJava = false;
- }
- }
-
- // Format bytes to human readable
- formatBytes(bytes: number): string {
- if (bytes === 0) return "0 B";
- const k = 1024;
- const sizes = ["B", "KB", "MB", "GB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
- }
-
- // Format seconds to human readable
- formatTime(seconds: number): string {
- if (seconds === 0 || !isFinite(seconds)) return "--";
- if (seconds < 60) return `${Math.round(seconds)}s`;
- if (seconds < 3600) {
- const mins = Math.floor(seconds / 60);
- const secs = Math.round(seconds % 60);
- return `${mins}m ${secs}s`;
- }
- const hours = Math.floor(seconds / 3600);
- const mins = Math.floor((seconds % 3600) / 60);
- return `${hours}h ${mins}m`;
- }
-
- // Format date string
- formatDate(dateStr: string | null): string {
- if (!dateStr) return "--";
- try {
- const date = new Date(dateStr);
- return date.toLocaleDateString("en-US", {
- year: "2-digit",
- month: "2-digit",
- day: "2-digit",
- });
- } catch {
- return "--";
- }
- }
-
- // Legacy compatibility
- get availableJavaVersions(): number[] {
- return this.availableMajorVersions;
- }
-
- // AI Model loading methods
- async loadOllamaModels() {
- this.isLoadingOllamaModels = true;
- this.ollamaModelsError = "";
-
- try {
- const models = await invoke<ModelInfo[]>("list_ollama_models", {
- endpoint: this.settings.assistant.ollama_endpoint,
- });
- this.ollamaModels = models;
-
- // If no model is selected or selected model isn't available, select the first one
- if (models.length > 0) {
- const currentModel = this.settings.assistant.ollama_model;
- const modelExists = models.some((m) => m.id === currentModel);
- if (!modelExists) {
- this.settings.assistant.ollama_model = models[0].id;
- }
- }
- } catch (e) {
- console.error("Failed to load Ollama models:", e);
- this.ollamaModelsError = String(e);
- this.ollamaModels = [];
- } finally {
- this.isLoadingOllamaModels = false;
- }
- }
-
- async loadOpenaiModels() {
- if (!this.settings.assistant.openai_api_key) {
- this.openaiModelsError = "API key required";
- this.openaiModels = [];
- return;
- }
-
- this.isLoadingOpenaiModels = true;
- this.openaiModelsError = "";
-
- try {
- const models = await invoke<ModelInfo[]>("list_openai_models");
- this.openaiModels = models;
-
- // If no model is selected or selected model isn't available, select the first one
- if (models.length > 0) {
- const currentModel = this.settings.assistant.openai_model;
- const modelExists = models.some((m) => m.id === currentModel);
- if (!modelExists) {
- this.settings.assistant.openai_model = models[0].id;
- }
- }
- } catch (e) {
- console.error("Failed to load OpenAI models:", e);
- this.openaiModelsError = String(e);
- this.openaiModels = [];
- } finally {
- this.isLoadingOpenaiModels = false;
- }
- }
-
- // Computed: get model options for current provider
- get currentModelOptions(): { value: string; label: string; details?: string }[] {
- const provider = this.settings.assistant.llm_provider;
-
- if (provider === "ollama") {
- if (this.ollamaModels.length === 0) {
- // Return fallback options if no models loaded
- return [
- { value: "llama3", label: "Llama 3" },
- { value: "llama3.1", label: "Llama 3.1" },
- { value: "llama3.2", label: "Llama 3.2" },
- { value: "mistral", label: "Mistral" },
- { value: "gemma2", label: "Gemma 2" },
- { value: "qwen2.5", label: "Qwen 2.5" },
- { value: "phi3", label: "Phi-3" },
- { value: "codellama", label: "Code Llama" },
- ];
- }
- return this.ollamaModels.map((m) => ({
- value: m.id,
- label: m.name,
- details: m.size ? `${m.size}${m.details ? ` - ${m.details}` : ""}` : m.details,
- }));
- } else if (provider === "openai") {
- if (this.openaiModels.length === 0) {
- // Return fallback options if no models loaded
- return [
- { value: "gpt-4o", label: "GPT-4o" },
- { value: "gpt-4o-mini", label: "GPT-4o Mini" },
- { value: "gpt-4-turbo", label: "GPT-4 Turbo" },
- { value: "gpt-4", label: "GPT-4" },
- { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
- ];
- }
- return this.openaiModels.map((m) => ({
- value: m.id,
- label: m.name,
- details: m.details,
- }));
- }
-
- return [];
- }
-}
-
-export const settingsState = new SettingsState();
diff --git a/packages/ui/src/stores/ui-store.ts b/packages/ui/src/stores/ui-store.ts
new file mode 100644
index 0000000..89b9191
--- /dev/null
+++ b/packages/ui/src/stores/ui-store.ts
@@ -0,0 +1,42 @@
+import { create } from "zustand";
+
+export type ViewType = "home" | "versions" | "settings" | "guide" | "instances";
+
+interface UIState {
+ // State
+ currentView: ViewType;
+ showConsole: boolean;
+ appVersion: string;
+
+ // Actions
+ toggleConsole: () => void;
+ setView: (view: ViewType) => void;
+ setAppVersion: (version: string) => void;
+}
+
+export const useUIStore = create<UIState>((set) => ({
+ // Initial state
+ currentView: "home",
+ showConsole: false,
+ appVersion: "...",
+
+ // Actions
+ toggleConsole: () => {
+ set((state) => ({ showConsole: !state.showConsole }));
+ },
+
+ setView: (view: ViewType) => {
+ set({ currentView: view });
+ },
+
+ setAppVersion: (version: string) => {
+ set({ appVersion: version });
+ },
+}));
+
+// Provide lowercase alias for compatibility with existing imports.
+// Use a function wrapper to ensure the named export exists as a callable value
+// at runtime (some bundlers/tree-shakers may remove simple aliases).
+export function useUiStore() {
+ return useUIStore();
+}
diff --git a/packages/ui/src/stores/ui.svelte.ts b/packages/ui/src/stores/ui.svelte.ts
deleted file mode 100644
index e88f6b4..0000000
--- a/packages/ui/src/stores/ui.svelte.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { type ViewType } from "../types";
-
-export class UIState {
- currentView: ViewType = $state("home");
- status = $state("Ready");
- showConsole = $state(false);
- appVersion = $state("...");
-
- private statusTimeout: ReturnType<typeof setTimeout> | null = null;
-
- setStatus(msg: string) {
- if (this.statusTimeout) clearTimeout(this.statusTimeout);
-
- this.status = msg;
-
- if (msg !== "Ready") {
- this.statusTimeout = setTimeout(() => {
- this.status = "Ready";
- }, 5000);
- }
- }
-
- toggleConsole() {
- this.showConsole = !this.showConsole;
- }
-
- setView(view: ViewType) {
- this.currentView = view;
- }
-}
-
-export const uiState = new UIState();
diff --git a/packages/ui/src/types/bindings/account.ts b/packages/ui/src/types/bindings/account.ts
new file mode 100644
index 0000000..168d138
--- /dev/null
+++ b/packages/ui/src/types/bindings/account.ts
@@ -0,0 +1,28 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { OfflineAccount } from "./auth";
+
+export type AccountStorage = { file_path: string };
+
+/**
+ * Stored account data for persistence
+ */
+export type AccountStore = {
+ accounts: Array<StoredAccount>;
+ active_account_id: string | null;
+};
+
+export type StoredAccount =
+ | ({ type: "Offline" } & OfflineAccount)
+ | ({ type: "Microsoft" } & StoredMicrosoftAccount);
+
+/**
+ * Microsoft account with refresh token for persistence
+ */
+export type StoredMicrosoftAccount = {
+ username: string;
+ uuid: string;
+ access_token: string;
+ refresh_token: string | null;
+ ms_refresh_token: string | null;
+ expires_at: bigint;
+};
diff --git a/packages/ui/src/types/bindings/assistant.ts b/packages/ui/src/types/bindings/assistant.ts
new file mode 100644
index 0000000..827f008
--- /dev/null
+++ b/packages/ui/src/types/bindings/assistant.ts
@@ -0,0 +1,25 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type GenerationStats = {
+ totalDuration: bigint;
+ loadDuration: bigint;
+ promptEvalCount: bigint;
+ promptEvalDuration: bigint;
+ evalCount: bigint;
+ evalDuration: bigint;
+};
+
+export type Message = { role: string; content: string };
+
+export type ModelInfo = {
+ id: string;
+ name: string;
+ size: string | null;
+ details: string | null;
+};
+
+export type StreamChunk = {
+ content: string;
+ done: boolean;
+ stats: GenerationStats | null;
+};
diff --git a/packages/ui/src/types/bindings/auth.ts b/packages/ui/src/types/bindings/auth.ts
new file mode 100644
index 0000000..563a924
--- /dev/null
+++ b/packages/ui/src/types/bindings/auth.ts
@@ -0,0 +1,32 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type Account =
+ | ({ type: "offline" } & OfflineAccount)
+ | ({ type: "microsoft" } & MicrosoftAccount);
+
+export type DeviceCodeResponse = {
+ userCode: string;
+ deviceCode: string;
+ verificationUri: string;
+ expiresIn: bigint;
+ interval: bigint;
+ message: string | null;
+};
+
+export type MicrosoftAccount = {
+ username: string;
+ uuid: string;
+ accessToken: string;
+ refreshToken: string | null;
+ expiresAt: bigint;
+};
+
+export type MinecraftProfile = { id: string; name: string };
+
+export type OfflineAccount = { username: string; uuid: string };
+
+export type TokenResponse = {
+ access_token: string;
+ refresh_token: string | null;
+ expires_in: bigint;
+};
diff --git a/packages/ui/src/types/bindings/config.ts b/packages/ui/src/types/bindings/config.ts
new file mode 100644
index 0000000..e9de4f5
--- /dev/null
+++ b/packages/ui/src/types/bindings/config.ts
@@ -0,0 +1,61 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type AssistantConfig = {
+ enabled: boolean;
+ llmProvider: string;
+ ollamaEndpoint: string;
+ ollamaModel: string;
+ openaiApiKey: string | null;
+ openaiEndpoint: string;
+ openaiModel: string;
+ systemPrompt: string;
+ responseLanguage: string;
+ ttsEnabled: boolean;
+ ttsProvider: string;
+};
+
+/**
+ * Feature-gated arguments configuration
+ */
+export type FeatureFlags = {
+ /**
+ * Demo user: enables demo-related arguments when rules require it
+ */
+ demoUser: boolean;
+ /**
+ * Quick Play: enable quick play arguments
+ */
+ quickPlayEnabled: boolean;
+ /**
+ * Quick Play singleplayer world path (if provided)
+ */
+ quickPlayPath: string | null;
+ /**
+ * Quick Play singleplayer flag
+ */
+ quickPlaySingleplayer: boolean;
+ /**
+ * Quick Play multiplayer server address (optional)
+ */
+ quickPlayMultiplayerServer: string | null;
+};
+
+export type LauncherConfig = {
+ minMemory: number;
+ maxMemory: number;
+ javaPath: string;
+ width: number;
+ height: number;
+ downloadThreads: number;
+ customBackgroundPath: string | null;
+ enableGpuAcceleration: boolean;
+ enableVisualEffects: boolean;
+ activeEffect: string;
+ theme: string;
+ logUploadService: string;
+ pastebinApiKey: string | null;
+ assistant: AssistantConfig;
+ useSharedCaches: boolean;
+ keepLegacyPerInstanceStorage: boolean;
+ featureFlags: FeatureFlags;
+};
diff --git a/packages/ui/src/types/bindings/core.ts b/packages/ui/src/types/bindings/core.ts
new file mode 100644
index 0000000..94e3bde
--- /dev/null
+++ b/packages/ui/src/types/bindings/core.ts
@@ -0,0 +1,47 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * File information for instance file browser
+ */
+export type FileInfo = {
+ name: string;
+ path: string;
+ isDirectory: boolean;
+ size: bigint;
+ modified: bigint;
+};
+
+export type GithubRelease = {
+ tagName: string;
+ name: string;
+ publishedAt: string;
+ body: string;
+ htmlUrl: string;
+};
+
+/**
+ * Installed version info
+ */
+export type InstalledVersion = { id: string; type: string };
+
+/**
+ * Migrate instance caches to shared global caches
+ */
+export type MigrationResult = {
+ movedFiles: number;
+ hardlinks: number;
+ copies: number;
+ savedBytes: bigint;
+ savedMb: number;
+};
+
+export type PastebinResponse = { url: string };
+
+/**
+ * Version metadata for display in the UI
+ */
+export type VersionMetadata = {
+ id: string;
+ javaVersion: bigint | null;
+ isInstalled: boolean;
+};
diff --git a/packages/ui/src/types/bindings/downloader.ts b/packages/ui/src/types/bindings/downloader.ts
new file mode 100644
index 0000000..f2be278
--- /dev/null
+++ b/packages/ui/src/types/bindings/downloader.ts
@@ -0,0 +1,73 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Metadata for resumable downloads stored in .part.meta file
+ */
+export type DownloadMetadata = {
+ url: string;
+ fileName: string;
+ totalSize: bigint;
+ downloadedBytes: bigint;
+ checksum: string | null;
+ timestamp: bigint;
+ segments: Array<DownloadSegment>;
+};
+
+/**
+ * Download queue for persistence
+ */
+export type DownloadQueue = { pendingDownloads: Array<PendingJavaDownload> };
+
+/**
+ * A download segment for multi-segment parallel downloading
+ */
+export type DownloadSegment = {
+ start: bigint;
+ end: bigint;
+ downloaded: bigint;
+ completed: boolean;
+};
+
+export type DownloadTask = {
+ url: string;
+ path: string;
+ sha1: string | null;
+ sha256: string | null;
+};
+
+/**
+ * Progress event for Java download
+ */
+export type JavaDownloadProgress = {
+ fileName: string;
+ downloadedBytes: bigint;
+ totalBytes: bigint;
+ speedBytesPerSec: bigint;
+ etaSeconds: bigint;
+ status: string;
+ percentage: number;
+};
+
+/**
+ * Pending download task for queue persistence
+ */
+export type PendingJavaDownload = {
+ majorVersion: number;
+ imageType: string;
+ downloadUrl: string;
+ fileName: string;
+ fileSize: bigint;
+ checksum: string | null;
+ installPath: string;
+ createdAt: bigint;
+};
+
+export type ProgressEvent = {
+ file: string;
+ downloaded: bigint;
+ total: bigint;
+ status: string;
+ completedFiles: number;
+ totalFiles: number;
+ totalDownloadedBytes: bigint;
+};
diff --git a/packages/ui/src/types/bindings/fabric.ts b/packages/ui/src/types/bindings/fabric.ts
new file mode 100644
index 0000000..181f8be
--- /dev/null
+++ b/packages/ui/src/types/bindings/fabric.ts
@@ -0,0 +1,74 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Represents a Minecraft version supported by Fabric.
+ */
+export type FabricGameVersion = { version: string; stable: boolean };
+
+/**
+ * Represents a Fabric intermediary mapping version.
+ */
+export type FabricIntermediaryVersion = {
+ maven: string;
+ version: string;
+ stable: boolean;
+};
+
+/**
+ * Launcher metadata from Fabric Meta API.
+ */
+export type FabricLauncherMeta = {
+ version: number;
+ libraries: FabricLibraries;
+ mainClass: FabricMainClass;
+};
+
+/**
+ * Libraries required by Fabric loader.
+ */
+export type FabricLibraries = {
+ client: Array<FabricLibrary>;
+ common: Array<FabricLibrary>;
+ server: Array<FabricLibrary>;
+};
+
+/**
+ * A single Fabric library dependency.
+ */
+export type FabricLibrary = { name: string; url: string | null };
+
+/**
+ * Represents a combined loader + intermediary version entry.
+ */
+export type FabricLoaderEntry = {
+ loader: FabricLoaderVersion;
+ intermediary: FabricIntermediaryVersion;
+ launcherMeta: FabricLauncherMeta;
+};
+
+/**
+ * Represents a Fabric loader version from the Meta API.
+ */
+export type FabricLoaderVersion = {
+ separator: string;
+ build: number;
+ maven: string;
+ version: string;
+ stable: boolean;
+};
+
+/**
+ * Main class configuration for Fabric.
+ * Can be either a struct with client/server fields or a simple string.
+ */
+export type FabricMainClass = { client: string; server: string } | string;
+
+/**
+ * Information about an installed Fabric version.
+ */
+export type InstalledFabricVersion = {
+ id: string;
+ minecraftVersion: string;
+ loaderVersion: string;
+ path: string;
+};
diff --git a/packages/ui/src/types/bindings/forge.ts b/packages/ui/src/types/bindings/forge.ts
new file mode 100644
index 0000000..a9790e7
--- /dev/null
+++ b/packages/ui/src/types/bindings/forge.ts
@@ -0,0 +1,21 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Represents a Forge version entry.
+ */
+export type ForgeVersion = {
+ version: string;
+ minecraftVersion: string;
+ recommended: boolean;
+ latest: boolean;
+};
+
+/**
+ * Information about an installed Forge version.
+ */
+export type InstalledForgeVersion = {
+ id: string;
+ minecraftVersion: string;
+ forgeVersion: string;
+ path: string;
+};
diff --git a/packages/ui/src/types/bindings/game-version.ts b/packages/ui/src/types/bindings/game-version.ts
new file mode 100644
index 0000000..1b1c395
--- /dev/null
+++ b/packages/ui/src/types/bindings/game-version.ts
@@ -0,0 +1,89 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type Arguments = {
+ game: Record<string, unknown>;
+ jvm: Record<string, unknown>;
+};
+
+export type AssetIndex = {
+ id: string;
+ sha1: string;
+ size: bigint;
+ url: string;
+ totalSize: bigint | null;
+};
+
+export type DownloadArtifact = {
+ sha1: string | null;
+ size: bigint | null;
+ url: string;
+ path: string | null;
+};
+
+export type Downloads = {
+ client: DownloadArtifact;
+ server: DownloadArtifact | null;
+};
+
+/**
+ * Represents a Minecraft version JSON, supporting both vanilla and modded (Fabric/Forge) formats.
+ * Modded versions use `inheritsFrom` to reference a parent vanilla version.
+ */
+export type GameVersion = {
+ id: string;
+ /**
+ * Optional for mod loaders that inherit from vanilla
+ */
+ downloads: Downloads | null;
+ /**
+ * Optional for mod loaders that inherit from vanilla
+ */
+ assetIndex: AssetIndex | null;
+ libraries: Array<Library>;
+ mainClass: string;
+ minecraftArguments: string | null;
+ arguments: Arguments | null;
+ javaVersion: JavaVersion | null;
+ /**
+ * For mod loaders: the vanilla version this inherits from
+ */
+ inheritsFrom: string | null;
+ /**
+ * Fabric/Forge may specify a custom assets version
+ */
+ assets: string | null;
+ /**
+ * Release type (release, snapshot, old_beta, etc.)
+ */
+ type: string | null;
+};
+
+export type JavaVersion = { component: string; majorVersion: bigint };
+
+export type Library = {
+ downloads: LibraryDownloads | null;
+ name: string;
+ rules: Array<Rule> | null;
+ natives: Record<string, unknown>;
+ /**
+ * Maven repository URL for mod loader libraries
+ */
+ url: string | null;
+};
+
+export type LibraryDownloads = {
+ artifact: DownloadArtifact | null;
+ classifiers: Record<string, unknown>;
+};
+
+export type OsRule = {
+ name: string | null;
+ version: string | null;
+ arch: string | null;
+};
+
+export type Rule = {
+ action: string;
+ os: OsRule | null;
+ features: Record<string, unknown>;
+};
diff --git a/packages/ui/src/types/bindings/index.ts b/packages/ui/src/types/bindings/index.ts
new file mode 100644
index 0000000..9bde037
--- /dev/null
+++ b/packages/ui/src/types/bindings/index.ts
@@ -0,0 +1,12 @@
+export * from "./account";
+export * from "./assistant";
+export * from "./auth";
+export * from "./config";
+export * from "./core";
+export * from "./downloader";
+export * from "./fabric";
+export * from "./forge";
+export * from "./game-version";
+export * from "./instance";
+export * from "./java";
+export * from "./manifest";
diff --git a/packages/ui/src/types/bindings/instance.ts b/packages/ui/src/types/bindings/instance.ts
new file mode 100644
index 0000000..2c4f8ae
--- /dev/null
+++ b/packages/ui/src/types/bindings/instance.ts
@@ -0,0 +1,33 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Represents a game instance/profile
+ */
+export type Instance = {
+ id: string;
+ name: string;
+ gameDir: string;
+ versionId: string | null;
+ createdAt: bigint;
+ lastPlayed: bigint | null;
+ iconPath: string | null;
+ notes: string | null;
+ modLoader: string | null;
+ modLoaderVersion: string | null;
+ jvmArgsOverride: string | null;
+ memoryOverride: MemoryOverride | null;
+ javaPathOverride: string | null;
+};
+
+/**
+ * Configuration for all instances
+ */
+export type InstanceConfig = {
+ instances: Array<Instance>;
+ activeInstanceId: string | null;
+};
+
+/**
+ * Memory settings override for an instance
+ */
+export type MemoryOverride = { min: number; max: number };
diff --git a/packages/ui/src/types/bindings/java/core.ts b/packages/ui/src/types/bindings/java/core.ts
new file mode 100644
index 0000000..099dea9
--- /dev/null
+++ b/packages/ui/src/types/bindings/java/core.ts
@@ -0,0 +1,41 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type JavaCatalog = {
+ releases: Array<JavaReleaseInfo>;
+ availableMajorVersions: Array<number>;
+ ltsVersions: Array<number>;
+ cachedAt: bigint;
+};
+
+export type JavaDownloadInfo = {
+ version: string;
+ release_name: string;
+ download_url: string;
+ file_name: string;
+ file_size: bigint;
+ checksum: string | null;
+ image_type: string;
+};
+
+export type JavaInstallation = {
+ path: string;
+ version: string;
+ arch: string;
+ vendor: string;
+ source: string;
+ is64bit: boolean;
+};
+
+export type JavaReleaseInfo = {
+ majorVersion: number;
+ imageType: string;
+ version: string;
+ releaseName: string;
+ releaseDate: string | null;
+ fileSize: bigint;
+ checksum: string | null;
+ downloadUrl: string;
+ isLts: boolean;
+ isAvailable: boolean;
+ architecture: string;
+};
diff --git a/packages/ui/src/types/bindings/java/index.ts b/packages/ui/src/types/bindings/java/index.ts
new file mode 100644
index 0000000..2f2754c
--- /dev/null
+++ b/packages/ui/src/types/bindings/java/index.ts
@@ -0,0 +1,3 @@
+export * from "./core";
+export * from "./persistence";
+export * from "./providers";
diff --git a/packages/ui/src/types/bindings/java/persistence.ts b/packages/ui/src/types/bindings/java/persistence.ts
new file mode 100644
index 0000000..7a2b576
--- /dev/null
+++ b/packages/ui/src/types/bindings/java/persistence.ts
@@ -0,0 +1,7 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type JavaConfig = {
+ user_defined_paths: Array<string>;
+ preferred_java_path: string | null;
+ last_detection_time: bigint;
+};
diff --git a/packages/ui/src/types/bindings/java/providers/adoptium.ts b/packages/ui/src/types/bindings/java/providers/adoptium.ts
new file mode 100644
index 0000000..65fc42b
--- /dev/null
+++ b/packages/ui/src/types/bindings/java/providers/adoptium.ts
@@ -0,0 +1,37 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type AdoptiumAsset = {
+ binary: AdoptiumBinary;
+ release_name: string;
+ version: AdoptiumVersionData;
+};
+
+export type AdoptiumBinary = {
+ os: string;
+ architecture: string;
+ image_type: string;
+ package: AdoptiumPackage;
+ updated_at: string | null;
+};
+
+export type AdoptiumPackage = {
+ name: string;
+ link: string;
+ size: bigint;
+ checksum: string | null;
+};
+
+export type AdoptiumVersionData = {
+ major: number;
+ minor: number;
+ security: number;
+ semver: string;
+ openjdk_version: string;
+};
+
+export type AvailableReleases = {
+ available_releases: Array<number>;
+ available_lts_releases: Array<number>;
+ most_recent_lts: number | null;
+ most_recent_feature_release: number | null;
+};
diff --git a/packages/ui/src/types/bindings/java/providers/index.ts b/packages/ui/src/types/bindings/java/providers/index.ts
new file mode 100644
index 0000000..3e28711
--- /dev/null
+++ b/packages/ui/src/types/bindings/java/providers/index.ts
@@ -0,0 +1 @@
+export * from "./adoptium";
diff --git a/packages/ui/src/types/bindings/manifest.ts b/packages/ui/src/types/bindings/manifest.ts
new file mode 100644
index 0000000..2180962
--- /dev/null
+++ b/packages/ui/src/types/bindings/manifest.ts
@@ -0,0 +1,22 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type Latest = { release: string; snapshot: string };
+
+export type Version = {
+ id: string;
+ type: string;
+ url: string;
+ time: string;
+ releaseTime: string;
+ /**
+ * Java version requirement (major version number)
+ * This is populated from the version JSON file if the version is installed locally
+ */
+ javaVersion: bigint | null;
+ /**
+ * Whether this version is installed locally
+ */
+ isInstalled: boolean | null;
+};
+
+export type VersionManifest = { latest: Latest; versions: Array<Version> };
diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts
index b4412b8..9e592d7 100644
--- a/packages/ui/src/types/index.ts
+++ b/packages/ui/src/types/index.ts
@@ -1,232 +1 @@
-export type ViewType = "home" | "versions" | "settings" | "guide" | "instances";
-
-export interface Version {
- id: string;
- type: string;
- url: string;
- time: string;
- releaseTime: string;
- javaVersion?: number; // Java major version requirement (e.g., 8, 17, 21)
- isInstalled?: boolean; // Whether this version is installed locally
-}
-
-export interface Account {
- type: "Offline" | "Microsoft";
- username: string;
- uuid: string;
- access_token?: string;
- refresh_token?: string;
- expires_at?: number; // Unix timestamp for Microsoft accounts
-}
-
-export interface DeviceCodeResponse {
- user_code: string;
- device_code: string;
- verification_uri: string;
- expires_in: number;
- interval: number;
- message?: string;
-}
-
-export interface AssistantConfig {
- enabled: boolean;
- llm_provider: "ollama" | "openai";
- // Ollama settings
- ollama_endpoint: string;
- ollama_model: string;
- // OpenAI settings
- openai_api_key?: string;
- openai_endpoint: string;
- openai_model: string;
- // Common settings
- system_prompt: string;
- response_language: string;
- // TTS settings
- tts_enabled: boolean;
- tts_provider: string;
-}
-
-export interface ModelInfo {
- id: string;
- name: string;
- size?: string;
- details?: string;
-}
-
-export interface LauncherConfig {
- min_memory: number;
- max_memory: number;
- java_path: string;
- width: number;
- height: number;
- download_threads: number;
- custom_background_path?: string;
- enable_gpu_acceleration: boolean;
- enable_visual_effects: boolean;
- active_effect: string;
- theme: string;
- log_upload_service: "paste.rs" | "pastebin.com";
- pastebin_api_key?: string;
- assistant: AssistantConfig;
- // Storage management
- use_shared_caches: boolean;
- keep_legacy_per_instance_storage: boolean;
- // Feature-gated argument flags
- feature_flags: FeatureFlags;
-}
-
-export interface FeatureFlags {
- demo_user: boolean;
- quick_play_enabled: boolean;
- quick_play_path?: string;
- quick_play_singleplayer: boolean;
- quick_play_multiplayer_server?: string;
-}
-
-export interface JavaInstallation {
- path: string;
- version: string;
- is_64bit: boolean;
-}
-
-export interface JavaDownloadInfo {
- version: string;
- release_name: string;
- download_url: string;
- file_name: string;
- file_size: number;
- checksum: string | null;
- image_type: string;
-}
-
-export interface JavaReleaseInfo {
- major_version: number;
- image_type: string;
- version: string;
- release_name: string;
- release_date: string | null;
- file_size: number;
- checksum: string | null;
- download_url: string;
- is_lts: boolean;
- is_available: boolean;
- architecture: string;
-}
-
-export interface JavaCatalog {
- releases: JavaReleaseInfo[];
- available_major_versions: number[];
- lts_versions: number[];
- cached_at: number;
-}
-
-export interface JavaDownloadProgress {
- file_name: string;
- downloaded_bytes: number;
- total_bytes: number;
- speed_bytes_per_sec: number;
- eta_seconds: number;
- status: string;
- percentage: number;
-}
-
-export interface PendingJavaDownload {
- major_version: number;
- image_type: string;
- download_url: string;
- file_name: string;
- file_size: number;
- checksum: string | null;
- install_path: string;
- created_at: number;
-}
-
-export type JavaDownloadSource = "adoptium" | "mojang" | "azul";
-
-// ==================== Fabric Types ====================
-
-export interface FabricGameVersion {
- version: string;
- stable: boolean;
-}
-
-export interface FabricLoaderVersion {
- separator: string;
- build: number;
- maven: string;
- version: string;
- stable: boolean;
-}
-
-export interface FabricLoaderEntry {
- loader: FabricLoaderVersion;
- intermediary: {
- maven: string;
- version: string;
- stable: boolean;
- };
- launcherMeta: {
- version: number;
- mainClass: {
- client: string;
- server: string;
- };
- };
-}
-
-export interface InstalledFabricVersion {
- id: string;
- minecraft_version: string;
- loader_version: string;
- path: string;
-}
-
-// ==================== Forge Types ====================
-
-export interface ForgeVersion {
- version: string;
- minecraft_version: string;
- recommended: boolean;
- latest: boolean;
-}
-
-export interface InstalledForgeVersion {
- id: string;
- minecraft_version: string;
- forge_version: string;
- path: string;
-}
-
-// ==================== Mod Loader Type ====================
-
-export type ModLoaderType = "vanilla" | "fabric" | "forge";
-
-// ==================== Instance Types ====================
-
-export interface Instance {
- id: string;
- name: string;
- game_dir: string;
- version_id?: string;
- created_at: number;
- last_played?: number;
- icon_path?: string;
- notes?: string;
- mod_loader?: string;
- mod_loader_version?: string;
- jvm_args_override?: string;
- memory_override?: MemoryOverride;
-}
-
-export interface MemoryOverride {
- min: number; // MB
- max: number; // MB
-}
-
-export interface FileInfo {
- name: string;
- path: string;
- is_directory: boolean;
- size: number;
- modified: number;
-}
+export * from "./bindings";
diff --git a/packages/ui/svelte.config.js b/packages/ui/svelte.config.js
deleted file mode 100644
index a710f1b..0000000
--- a/packages/ui/svelte.config.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
-
-/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
-export default {
- // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
- // for more information about preprocessors
- preprocess: vitePreprocess(),
-};
diff --git a/packages/ui/tsconfig.app.json b/packages/ui/tsconfig.app.json
index addb46d..54f0bdf 100644
--- a/packages/ui/tsconfig.app.json
+++ b/packages/ui/tsconfig.app.json
@@ -1,22 +1,34 @@
{
- "extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
"moduleResolution": "bundler",
- "types": ["svelte", "vite/client"],
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
"noEmit": true,
- /**
- * Typecheck JS in `.svelte` and `.js` files by default.
- * Disable checkJs if you'd like to use dynamic types in JS.
- * Note that setting allowJs false does not prevent the use
- * of JS in `.svelte` files.
- */
- "allowJs": true,
- "checkJs": true,
- "moduleDetection": "force"
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ /* Paths */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
},
- "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
+ "include": ["src"]
}
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
index d32ff68..fec8c8e 100644
--- a/packages/ui/tsconfig.json
+++ b/packages/ui/tsconfig.json
@@ -1,4 +1,13 @@
{
"files": [],
- "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
}
diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts
index 32610e2..27ce1ff 100644
--- a/packages/ui/vite.config.ts
+++ b/packages/ui/vite.config.ts
@@ -1,26 +1,18 @@
-import { defineConfig } from "vite";
-import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import path from "path";
+import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
- plugins: [tailwindcss(), svelte()],
-
- // Fix for Tauri + Vite HMR
- server: {
- host: true,
- strictPort: true,
- hmr: {
- protocol: "ws",
- host: "localhost",
- port: 5173,
- },
- watch: {
- usePolling: true,
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ "@components": path.resolve(__dirname, "./src/components"),
+ "@stores": path.resolve(__dirname, "./src/stores"),
+ "@types": path.resolve(__dirname, "./src/types"),
+ "@pages": path.resolve(__dirname, "./src/pages"),
},
},
-
- // Ensure compatibility with Tauri
- clearScreen: false,
- envPrefix: ["VITE_", "TAURI_"],
});