diff options
| author | 2026-02-25 02:06:07 +0800 | |
|---|---|---|
| committer | 2026-02-25 02:06:07 +0800 | |
| commit | 78ac61904d78d558d092eff08c9f261cbdb187e8 (patch) | |
| tree | 96f68d1f1554ee3a0532793afaaa52b0c73dcbec /packages/ui | |
| parent | 8ff3af6cb908fd824b512379dd21ed4f595ab6bb (diff) | |
| parent | 329734b23957b84cde2af459fa61c7385fb5b5f1 (diff) | |
| download | DropOut-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')
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, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - - // Enhanced markdown parser with Emoji and GitHub specific features - function formatBody(body: string) { - if (!body) return ''; - - // Escape HTML first to prevent XSS - let processed = escapeHtml(body); - - // Emoji map (common GitHub emojis) - const emojiMap: Record<string, string> = { - ':tada:': '🎉', ':sparkles:': '✨', ':bug:': '🐛', ':memo:': '📝', - ':rocket:': '🚀', ':white_check_mark:': '✅', ':construction:': '🚧', - ':recycle:': '♻️', ':wrench:': '🔧', ':package:': '📦', - ':arrow_up:': '⬆️', ':arrow_down:': '⬇️', ':warning:': '⚠️', - ':fire:': '🔥', ':heart:': '❤️', ':star:': '⭐', ':zap:': '⚡', - ':art:': '🎨', ':lipstick:': '💄', ':globe_with_meridians:': '🌐' - }; - - // Replace emojis - processed = processed.replace(/:[a-z0-9_]+:/g, (match) => emojiMap[match] || match); - - // GitHub commit hash linking (simple version for 7-40 hex chars inside backticks) - processed = processed.replace(/`([0-9a-f]{7,40})`/g, (match, hash) => { - return `<a href="https://github.com/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 > 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 > 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"> + > 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_"], }); |