diff options
| author | 2026-01-19 14:17:32 +0800 | |
|---|---|---|
| committer | 2026-01-19 14:17:32 +0800 | |
| commit | da0d79f0db873c08fab3bc85023167e174d18b0e (patch) | |
| tree | 4a1934780d0d723ec8b834088188d4714f2cf3e7 /packages | |
| parent | 887e415314014c3da7db3048fa0e724f3078c5cb (diff) | |
| download | DropOut-da0d79f0db873c08fab3bc85023167e174d18b0e.tar.gz DropOut-da0d79f0db873c08fab3bc85023167e174d18b0e.zip | |
chore(ui): refactor workspace to monorepo
Diffstat (limited to 'packages')
46 files changed, 9859 insertions, 0 deletions
diff --git a/packages/ui/.gitignore b/packages/ui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/packages/ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md new file mode 100644 index 0000000..4b2d22b --- /dev/null +++ b/packages/ui/CHANGELOG.md @@ -0,0 +1,7 @@ +# 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 new file mode 100644 index 0000000..a45e2a0 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,47 @@ +# 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/index.html b/packages/ui/index.html new file mode 100644 index 0000000..4fe68e1 --- /dev/null +++ b/packages/ui/index.html @@ -0,0 +1,13 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Dropout</title> + </head> + <body> + <div id="app"></div> + <script type="module" src="/src/main.ts"></script> + </body> +</html> diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..943fddc --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,41 @@ +{ + "name": "@dropout/ui", + "version": "0.2.0-alpha.1", + "private": true, + "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" + }, + "dependencies": { + "@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", + "marked": "^17.0.1", + "node-emoji": "^2.2.0", + "prismjs": "^1.30.0" + }, + "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", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "vite": "npm:rolldown-vite@7.2.5" + } +} diff --git a/packages/ui/pnpm-lock.yaml b/packages/ui/pnpm-lock.yaml new file mode 100644 index 0000000..465b682 --- /dev/null +++ b/packages/ui/pnpm-lock.yaml @@ -0,0 +1,1363 @@ +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/vite.svg b/packages/ui/public/vite.svg new file mode 100644 index 0000000..ee9fada --- /dev/null +++ b/packages/ui/public/vite.svg @@ -0,0 +1 @@ +<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 new file mode 100644 index 0000000..f73e0a2 --- /dev/null +++ b/packages/ui/src/App.svelte @@ -0,0 +1,217 @@ +<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 new file mode 100644 index 0000000..63449b7 --- /dev/null +++ b/packages/ui/src/app.css @@ -0,0 +1,167 @@ +@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 new file mode 100644 index 0000000..8c056ce --- /dev/null +++ b/packages/ui/src/assets/svelte.svg @@ -0,0 +1 @@ +<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/components/AssistantView.svelte b/packages/ui/src/components/AssistantView.svelte new file mode 100644 index 0000000..54509a5 --- /dev/null +++ b/packages/ui/src/components/AssistantView.svelte @@ -0,0 +1,436 @@ +<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 new file mode 100644 index 0000000..19cf35d --- /dev/null +++ b/packages/ui/src/components/BottomBar.svelte @@ -0,0 +1,250 @@ +<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 new file mode 100644 index 0000000..dd866ee --- /dev/null +++ b/packages/ui/src/components/ConfigEditorModal.svelte @@ -0,0 +1,369 @@ +<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 new file mode 100644 index 0000000..0767471 --- /dev/null +++ b/packages/ui/src/components/CustomSelect.svelte @@ -0,0 +1,173 @@ +<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 new file mode 100644 index 0000000..573d9da --- /dev/null +++ b/packages/ui/src/components/HomeView.svelte @@ -0,0 +1,271 @@ +<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 new file mode 100644 index 0000000..c54cb98 --- /dev/null +++ b/packages/ui/src/components/InstanceCreationModal.svelte @@ -0,0 +1,485 @@ +<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 new file mode 100644 index 0000000..0856d93 --- /dev/null +++ b/packages/ui/src/components/InstanceEditorModal.svelte @@ -0,0 +1,439 @@ +<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 new file mode 100644 index 0000000..5334f9e --- /dev/null +++ b/packages/ui/src/components/InstancesView.svelte @@ -0,0 +1,259 @@ +<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 new file mode 100644 index 0000000..1886cd9 --- /dev/null +++ b/packages/ui/src/components/LoginModal.svelte @@ -0,0 +1,126 @@ +<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 new file mode 100644 index 0000000..50caa8c --- /dev/null +++ b/packages/ui/src/components/ModLoaderSelector.svelte @@ -0,0 +1,455 @@ +<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 new file mode 100644 index 0000000..7644b1a --- /dev/null +++ b/packages/ui/src/components/ParticleBackground.svelte @@ -0,0 +1,70 @@ +<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 new file mode 100644 index 0000000..0020506 --- /dev/null +++ b/packages/ui/src/components/SettingsView.svelte @@ -0,0 +1,1217 @@ +<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 new file mode 100644 index 0000000..83f4ac6 --- /dev/null +++ b/packages/ui/src/components/Sidebar.svelte @@ -0,0 +1,91 @@ +<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 new file mode 100644 index 0000000..4c981c7 --- /dev/null +++ b/packages/ui/src/components/StatusToast.svelte @@ -0,0 +1,42 @@ +<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 new file mode 100644 index 0000000..f1474d9 --- /dev/null +++ b/packages/ui/src/components/VersionsView.svelte @@ -0,0 +1,511 @@ +<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/lib/Counter.svelte b/packages/ui/src/lib/Counter.svelte new file mode 100644 index 0000000..37d75ce --- /dev/null +++ b/packages/ui/src/lib/Counter.svelte @@ -0,0 +1,10 @@ +<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 new file mode 100644 index 0000000..860952c --- /dev/null +++ b/packages/ui/src/lib/DownloadMonitor.svelte @@ -0,0 +1,201 @@ +<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 new file mode 100644 index 0000000..bc5edbc --- /dev/null +++ b/packages/ui/src/lib/GameConsole.svelte @@ -0,0 +1,304 @@ +<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 new file mode 100644 index 0000000..d2db529 --- /dev/null +++ b/packages/ui/src/lib/effects/ConstellationEffect.ts @@ -0,0 +1,162 @@ +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 new file mode 100644 index 0000000..357da9d --- /dev/null +++ b/packages/ui/src/lib/effects/SaturnEffect.ts @@ -0,0 +1,340 @@ +// Optimized Saturn Effect for low-end hardware +// Uses TypedArrays for memory efficiency and reduced particle density + +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 + + 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 + })!; + + // Initial resize will set up everything + this.resize(window.innerWidth, window.innerHeight); + this.initParticles(); + + this.animate = this.animate.bind(this); + this.animate(); + } + + // Public methods for external mouse event handling + // These can be called from any element that wants to control the Saturn rotation + + handleMouseDown(clientX: number) { + this.isDragging = true; + this.lastMouseX = clientX; + this.lastMouseTime = performance.now(); + this.mouseVelocities = []; + } + + 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) + this.mouseVelocities.push(velocity); + if (this.mouseVelocities.length > 5) { + this.mouseVelocities.shift(); + } + + // Apply direct rotation while dragging + this.angle += deltaX * 0.002; + } + + this.lastMouseX = clientX; + this.lastMouseTime = currentTime; + } + + handleMouseUp() { + if (this.isDragging && this.mouseVelocities.length > 0) { + this.applyFlingVelocity(); + } + this.isDragging = false; + } + + handleTouchStart(clientX: number) { + this.isDragging = true; + this.lastMouseX = clientX; + this.lastMouseTime = performance.now(); + this.mouseVelocities = []; + } + + 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; + } + + 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) + } + + 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); + + // Dynamic scaling based on screen size + const minDim = Math.min(width, height); + this.scaleFactor = 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) + 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++) { + 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++; + } + + // 2. Rings + const ringInner = 1.4; + const ringOuter = 2.3; + + for (let i = 0; i < ringCount; i++) { + const angle = Math.random() * Math.PI * 2; + const dist = Math.sqrt( + 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++; + } + } + + animate() { + this.ctx.clearRect(0, 0, this.width, this.height); + + // Normal blending + this.ctx.globalCompositeOperation = "source-over"; + + // Update rotation speed - decay towards base speed while maintaining direction + 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 + if (this.currentSpeed - this.baseSpeed < 0.00001) { + this.currentSpeed = this.baseSpeed; + } + } + + // Apply rotation with current speed and direction + this.angle += this.currentSpeed * this.rotationDirection; + } + + const cx = this.width * 0.6; + const cy = this.height * 0.5; + + // Pre-calculate rotation matrices + const rotationY = this.angle; + const rotationX = 0.4; + const rotationZ = 0.15; + + const sinY = Math.sin(rotationY); + const cosY = Math.cos(rotationY); + const sinX = Math.sin(rotationX); + const cosX = Math.cos(rotationX); + const sinZ = Math.sin(rotationZ); + const cosZ = Math.cos(rotationZ); + + const fov = 1500; + const scaleFactor = this.scaleFactor; + + if (!this.xyz || !this.types) return; + + 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 + const px = x * scaleFactor; + const py = y * scaleFactor; + const pz = z * scaleFactor; + + // 1. Rotate Y + 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; + + const scale = fov / (fov + z3); + + if (z3 > -fov) { + 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 + + // Optimization: Planet color vs Ring color + if (type === 0) { + // Planet: Warm White + this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`; + } else { + // Ring: Cool White + 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. + this.ctx.fillRect(x2d, y2d, size, size); + } + } + + this.animationId = requestAnimationFrame(this.animate); + } + + destroy() { + cancelAnimationFrame(this.animationId); + } +} diff --git a/packages/ui/src/lib/modLoaderApi.ts b/packages/ui/src/lib/modLoaderApi.ts new file mode 100644 index 0000000..75f404a --- /dev/null +++ b/packages/ui/src/lib/modLoaderApi.ts @@ -0,0 +1,106 @@ +/** + * 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/main.ts b/packages/ui/src/main.ts new file mode 100644 index 0000000..d47b930 --- /dev/null +++ b/packages/ui/src/main.ts @@ -0,0 +1,9 @@ +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/stores/assistant.svelte.ts b/packages/ui/src/stores/assistant.svelte.ts new file mode 100644 index 0000000..a3f47ea --- /dev/null +++ b/packages/ui/src/stores/assistant.svelte.ts @@ -0,0 +1,166 @@ +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.svelte.ts b/packages/ui/src/stores/auth.svelte.ts new file mode 100644 index 0000000..1b613a7 --- /dev/null +++ b/packages/ui/src/stores/auth.svelte.ts @@ -0,0 +1,192 @@ +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.svelte.ts b/packages/ui/src/stores/game.svelte.ts new file mode 100644 index 0000000..504d108 --- /dev/null +++ b/packages/ui/src/stores/game.svelte.ts @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..f4ac4e9 --- /dev/null +++ b/packages/ui/src/stores/instances.svelte.ts @@ -0,0 +1,109 @@ +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.svelte.ts b/packages/ui/src/stores/logs.svelte.ts new file mode 100644 index 0000000..c9d4acc --- /dev/null +++ b/packages/ui/src/stores/logs.svelte.ts @@ -0,0 +1,151 @@ +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.svelte.ts b/packages/ui/src/stores/releases.svelte.ts new file mode 100644 index 0000000..c858abb --- /dev/null +++ b/packages/ui/src/stores/releases.svelte.ts @@ -0,0 +1,36 @@ +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.svelte.ts b/packages/ui/src/stores/settings.svelte.ts new file mode 100644 index 0000000..5d20050 --- /dev/null +++ b/packages/ui/src/stores/settings.svelte.ts @@ -0,0 +1,570 @@ +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.svelte.ts b/packages/ui/src/stores/ui.svelte.ts new file mode 100644 index 0000000..e88f6b4 --- /dev/null +++ b/packages/ui/src/stores/ui.svelte.ts @@ -0,0 +1,32 @@ +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/index.ts b/packages/ui/src/types/index.ts new file mode 100644 index 0000000..b4412b8 --- /dev/null +++ b/packages/ui/src/types/index.ts @@ -0,0 +1,232 @@ +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; +} diff --git a/packages/ui/svelte.config.js b/packages/ui/svelte.config.js new file mode 100644 index 0000000..a710f1b --- /dev/null +++ b/packages/ui/svelte.config.js @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..addb46d --- /dev/null +++ b/packages/ui/tsconfig.app.json @@ -0,0 +1,22 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["svelte", "vite/client"], + "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" + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/packages/ui/tsconfig.node.json b/packages/ui/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/packages/ui/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts new file mode 100644 index 0000000..32610e2 --- /dev/null +++ b/packages/ui/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import tailwindcss from "@tailwindcss/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, + }, + }, + + // Ensure compatibility with Tauri + clearScreen: false, + envPrefix: ["VITE_", "TAURI_"], +}); |