diff options
| author | 2026-01-15 17:49:26 +0800 | |
|---|---|---|
| committer | 2026-01-15 17:49:26 +0800 | |
| commit | 32a9aceee42a2261b64f9e6effda522639576a5e (patch) | |
| tree | 4cae8d216c3093421addaa0450bc8004c537e373 /ui | |
| parent | ce4b0c2053d5d16f7091d74840d4a502401f1a4e (diff) | |
| parent | 31077dbd39a25eecd24a1dca0f8c9d1879265277 (diff) | |
| download | DropOut-32a9aceee42a2261b64f9e6effda522639576a5e.tar.gz DropOut-32a9aceee42a2261b64f9e6effda522639576a5e.zip | |
Merge pull request #30 from HsiangNianian/main
Diffstat (limited to 'ui')
26 files changed, 3562 insertions, 387 deletions
diff --git a/ui/package-lock.json b/ui/package-lock.json index 2682169..a142994 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,7 +9,9 @@ "version": "0.0.0", "dependencies": { "@tauri-apps/api": "^2.9.1", - "@tauri-apps/plugin-shell": "^2.3.4" + "@tauri-apps/plugin-dialog": "^2.5.0", + "@tauri-apps/plugin-shell": "^2.3.4", + "lucide-svelte": "^0.562.0" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", @@ -63,7 +65,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -74,7 +75,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -85,7 +85,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -95,14 +94,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -395,7 +392,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -722,6 +718,15 @@ "url": "https://opencollective.com/tauri" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", + "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-shell": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.4.tgz", @@ -753,7 +758,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -770,7 +774,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -783,7 +786,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -830,7 +832,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -921,7 +922,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -951,7 +951,6 @@ "version": "5.6.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz", "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", - "dev": true, "license": "MIT" }, "node_modules/electron-to-chromium": { @@ -989,14 +988,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -1060,7 +1057,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -1341,14 +1337,21 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, + "node_modules/lucide-svelte": { + "version": "0.562.0", + "resolved": "https://registry.npmmirror.com/lucide-svelte/-/lucide-svelte-0.562.0.tgz", + "integrity": "sha512-kSJDH/55lf0mun/o4nqWBXOcq0fWYzPeIjbTD97ywoeumAB9kWxtM06gC7oynqjtK3XhAljWSz5RafIzPEYIQA==", + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -1531,7 +1534,6 @@ "version": "5.46.3", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.3.tgz", "integrity": "sha512-Y5juST3x+/ySty5tYJCVWa6Corkxpt25bUZQHqOceg9xfMUtDsFx6rCsG6cYf1cA6vzDi66HIvaki0byZZX95A==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -1777,7 +1779,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" } } diff --git a/ui/package.json b/ui/package.json index 0806781..5848109 100644 --- a/ui/package.json +++ b/ui/package.json @@ -29,6 +29,11 @@ }, "dependencies": { "@tauri-apps/api": "^2.9.1", - "@tauri-apps/plugin-shell": "^2.3.4" + "@tauri-apps/plugin-dialog": "^2.5.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" } } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index d48c01e..67e9cbc 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -14,9 +14,24 @@ importers: '@tauri-apps/api': specifier: ^2.9.1 version: 2.9.1 + '@tauri-apps/plugin-dialog': + specifier: ^2.5.0 + version: 2.5.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.3) + marked: + specifier: ^17.0.1 + version: 17.0.1 + node-emoji: + specifier: ^2.2.0 + version: 2.2.0 devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^6.2.1 @@ -179,6 +194,10 @@ packages: '@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: @@ -296,6 +315,12 @@ packages: '@tauri-apps/api@2.9.1': resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==} + '@tauri-apps/plugin-dialog@2.5.0': + resolution: {integrity: sha512-I0R0ygwRd9AN8Wj5GnzCogOlqu2+OWAtBd0zEC4+kQCI32fRowIyuhPCBoUv4h/lQt2bM39kHlxPHD5vDcFjiA==} + + '@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==} @@ -343,6 +368,10 @@ packages: 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'} @@ -365,6 +394,9 @@ packages: 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'} @@ -483,9 +515,19 @@ packages: 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'} @@ -495,6 +537,10 @@ packages: 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==} @@ -568,6 +614,10 @@ packages: 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'} @@ -606,6 +656,10 @@ packages: 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 @@ -717,6 +771,8 @@ snapshots: '@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 @@ -808,6 +864,14 @@ snapshots: '@tauri-apps/api@2.9.1': {} + '@tauri-apps/plugin-dialog@2.5.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 @@ -852,6 +916,8 @@ snapshots: caniuse-lite@1.0.30001764: {} + char-regex@1.0.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -866,6 +932,8 @@ snapshots: electron-to-chromium@1.5.267: {} + emojilib@2.4.0: {} + enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 @@ -947,14 +1015,27 @@ snapshots: locate-character@3.0.0: {} + lucide-svelte@0.562.0(svelte@5.46.3): + dependencies: + svelte: 5.46.3 + 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: {} @@ -1011,6 +1092,10 @@ snapshots: 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.3)(typescript@5.9.3): @@ -1059,6 +1144,8 @@ snapshots: 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 diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 3750f11..0bb31ae 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,64 +1,178 @@ <script lang="ts"> import { getVersion } from "@tauri-apps/api/app"; - import { onMount } from "svelte"; + // 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 Sidebar from "./components/Sidebar.svelte"; - import HomeView from "./components/HomeView.svelte"; - import VersionsView from "./components/VersionsView.svelte"; - import SettingsView from "./components/SettingsView.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 Sidebar from "./components/Sidebar.svelte"; import StatusToast from "./components/StatusToast.svelte"; - - // Stores - import { uiState } from "./stores/ui.svelte"; + import VersionsView from "./components/VersionsView.svelte"; +// Stores import { authState } from "./stores/auth.svelte"; - import { settingsState } from "./stores/settings.svelte"; import { gameState } from "./stores/game.svelte"; + import { settingsState } from "./stores/settings.svelte"; + import { uiState } from "./stores/ui.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 () => { authState.checkAccount(); - settingsState.loadSettings(); + await settingsState.loadSettings(); + await settingsState.detectJava(); gameState.loadVersions(); getVersion().then((v) => (uiState.appVersion = v)); + window.addEventListener("mousemove", handleMouseMove); + }); + + $effect(() => { + // ENFORCE DARK MODE: Always add 'dark' class and attribute + // This combined with the @variant dark in app.css ensures dark mode is always active + // regardless of system preference settings. + document.documentElement.classList.add('dark'); + document.documentElement.setAttribute('data-theme', 'dark'); + + // Ensure 'light' class is never present + document.documentElement.classList.remove('light'); + }); + + onDestroy(() => { + if (typeof window !== 'undefined') + window.removeEventListener("mousemove", handleMouseMove); }); </script> <div - class="flex h-screen bg-zinc-900 text-white font-sans overflow-hidden select-none" + class="relative h-screen w-screen overflow-hidden dark:text-white text-gray-900 font-sans selection:bg-indigo-500/30" > - <Sidebar /> - - <!-- Main Content --> - <main class="flex-1 flex flex-col relative min-w-0"> - <DownloadMonitor /> - <!-- Top Bar (Window Controls Placeholder) --> - <div - class="h-8 w-full bg-zinc-900/50 absolute top-0 left-0 z-50 drag-region" - data-tauri-drag-region - > - <!-- Windows/macOS controls would go here or be handled by OS --> - </div> + <!-- 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} - <!-- Background / Poster area --> - <div class="flex-1 relative overflow-hidden group"> {#if uiState.currentView === "home"} - <HomeView /> - {:else if uiState.currentView === "versions"} - <VersionsView /> - {:else if uiState.currentView === "settings"} - <SettingsView /> + <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> - <BottomBar /> - </main> + <!-- 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 === "versions"} + <VersionsView /> + {:else if uiState.currentView === "settings"} + <SettingsView /> + {/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 /> - - <GameConsole visible={uiState.showConsole} /> + + {#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/ui/src/app.css b/ui/src/app.css index f1d8c73..82aa72f 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -1 +1,165 @@ @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/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte index dcad9e8..abb0b23 100644 --- a/ui/src/components/BottomBar.svelte +++ b/ui/src/components/BottomBar.svelte @@ -2,21 +2,55 @@ import { authState } from "../stores/auth.svelte"; import { gameState } from "../stores/game.svelte"; import { uiState } from "../stores/ui.svelte"; + import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte'; + + let isVersionDropdownOpen = $state(false); + let dropdownRef: HTMLDivElement; + + let versionOptions = $derived( + gameState.versions.length === 0 + ? [{ id: "loading", type: "loading", label: "Loading..." }] + : gameState.versions.map(v => ({ + ...v, + label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}` + })) + ); + + function selectVersion(id: string) { + if (id !== "loading") { + gameState.selectedVersion = id; + isVersionDropdownOpen = false; + } + } + + function handleClickOutside(e: MouseEvent) { + if (dropdownRef && !dropdownRef.contains(e.target as Node)) { + isVersionDropdownOpen = false; + } + } + + $effect(() => { + if (isVersionDropdownOpen) { + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + } + }); </script> <div - class="h-24 bg-zinc-900 border-t border-zinc-800 flex items-center px-8 justify-between z-20 shadow-2xl" + class="h-20 bg-white/80 dark:bg-[#09090b]/90 border-t dark:border-white/10 border-black/5 flex items-center px-8 justify-between z-20 backdrop-blur-md" > - <div class="flex items-center gap-4"> + <!-- Account Area --> + <div class="flex items-center gap-6"> <div - class="flex items-center gap-4 cursor-pointer hover:opacity-80 transition-opacity" + class="group flex items-center gap-4 cursor-pointer" onclick={() => authState.openLoginModal()} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && authState.openLoginModal()} > <div - class="w-12 h-12 rounded bg-gradient-to-tr from-indigo-500 to-purple-500 shadow-lg flex items-center justify-center text-white font-bold text-xl overflow-hidden" + class="w-10 h-10 rounded-sm bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 flex items-center justify-center overflow-hidden transition-all group-hover:border-zinc-400 dark:group-hover:border-zinc-500" > {#if authState.currentAccount} <img @@ -25,64 +59,98 @@ class="w-full h-full" /> {:else} - ? + <User size={20} class="text-zinc-400" /> {/if} </div> <div> - <div class="font-bold text-white text-lg"> - {authState.currentAccount ? authState.currentAccount.username : "Click to Login"} + <div class="font-bold dark:text-white text-gray-900 text-sm group-hover:text-black dark:group-hover:text-zinc-200 transition-colors"> + {authState.currentAccount ? authState.currentAccount.username : "Login Account"} </div> - <div class="text-xs text-zinc-400 flex items-center gap-1"> + <div class="text-[10px] uppercase tracking-wider dark:text-zinc-500 text-gray-500 flex items-center gap-2"> <span class="w-1.5 h-1.5 rounded-full {authState.currentAccount - ? 'bg-green-500' - : 'bg-zinc-500'}" + ? 'bg-emerald-500' + : 'bg-zinc-400'}" ></span> - {authState.currentAccount ? "Ready" : "Guest"} + {authState.currentAccount ? "Online" : "Guest"} </div> </div> </div> + + <div class="h-8 w-px dark:bg-white/10 bg-black/10"></div> + <!-- Console Toggle --> <button - class="ml-4 text-xs text-zinc-500 hover:text-zinc-300 transition" + class="text-xs font-mono dark:text-zinc-500 text-gray-500 dark:hover:text-white hover:text-black transition-colors flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-black/5 dark:hover:bg-white/5" onclick={() => uiState.toggleConsole()} > - {uiState.showConsole ? "Hide Logs" : "Show Logs"} + <Terminal size={14} /> + {uiState.showConsole ? "HIDE LOGS" : "SHOW LOGS"} </button> </div> + <!-- Action Area --> <div class="flex items-center gap-4"> <div class="flex flex-col items-end mr-2"> - <label - for="version-select" - class="text-xs text-zinc-500 mb-1 uppercase font-bold tracking-wider" - >Version</label - > - <select - id="version-select" - bind:value={gameState.selectedVersion} - class="bg-zinc-950 text-zinc-200 border border-zinc-700 rounded px-4 py-2 hover:border-zinc-500 transition-colors cursor-pointer outline-none focus:ring-1 focus:ring-indigo-500 w-48" - > - {#if gameState.versions.length === 0} - <option>Loading...</option> - {:else} - {#each gameState.versions as version} - <option value={version.id}>{version.id} ({version.type})</option - > - {/each} + <!-- Custom Version Dropdown --> + <div class="relative" bind:this={dropdownRef}> + <button + type="button" + onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen} + class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left + dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md + text-sm font-mono dark:text-white text-gray-900 + dark:hover:border-zinc-600 hover:border-zinc-400 + focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 + transition-colors cursor-pointer outline-none" + > + <span class="truncate"> + {#if gameState.versions.length === 0} + Loading... + {:else} + {gameState.selectedVersion || "Select version"} + {/if} + </span> + <ChevronDown + size={14} + class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}" + /> + </button> + + {#if isVersionDropdownOpen} + <div + class="absolute z-50 w-full mt-1 py-1 dark:bg-zinc-900 bg-white border dark:border-zinc-700 border-zinc-300 rounded-md shadow-xl + max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1" + > + {#each versionOptions as version} + <button + type="button" + onclick={() => selectVersion(version.id)} + disabled={version.id === "loading"} + class="w-full flex items-center justify-between px-3 py-2 text-sm font-mono text-left + transition-colors outline-none + {version.id === gameState.selectedVersion + ? 'bg-indigo-600 text-white' + : 'dark:text-zinc-300 text-gray-700 dark:hover:bg-zinc-800 hover:bg-zinc-100'} + {version.id === 'loading' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}" + > + <span class="truncate">{version.label}</span> + {#if version.id === gameState.selectedVersion} + <Check size={14} class="shrink-0 ml-2" /> + {/if} + </button> + {/each} + </div> {/if} - </select> + </div> </div> <button onclick={() => gameState.startGame()} - class="bg-green-600 hover:bg-green-500 text-white font-bold h-14 px-12 rounded transition-all transform active:scale-95 shadow-[0_0_15px_rgba(22,163,74,0.4)] hover:shadow-[0_0_25px_rgba(22,163,74,0.6)] flex flex-col items-center justify-center uppercase tracking-wider text-lg" + class="bg-emerald-600 hover:bg-emerald-500 text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase" > - Play - <span - class="text-[10px] font-normal opacity-80 normal-case tracking-normal" - >Click to launch</span - > + <Play size={24} fill="currentColor" /> + <span>Launch</span> </button> </div> </div> diff --git a/ui/src/components/CustomSelect.svelte b/ui/src/components/CustomSelect.svelte new file mode 100644 index 0000000..2e89c75 --- /dev/null +++ b/ui/src/components/CustomSelect.svelte @@ -0,0 +1,136 @@ +<script lang="ts"> + import { ChevronDown, Check } from 'lucide-svelte'; + + interface Option { + value: string; + label: string; + disabled?: boolean; + } + + interface Props { + options: Option[]; + value: string; + placeholder?: string; + disabled?: boolean; + class?: string; + onchange?: (value: string) => void; + } + + let { + options, + value = $bindable(), + placeholder = "Select...", + disabled = false, + class: className = "", + onchange + }: Props = $props(); + + let isOpen = $state(false); + let containerRef: HTMLDivElement; + + let selectedOption = $derived(options.find(o => o.value === value)); + + function toggle() { + if (!disabled) { + isOpen = !isOpen; + } + } + + function select(option: Option) { + if (option.disabled) return; + value = option.value; + isOpen = false; + onchange?.(option.value); + } + + function handleKeydown(e: KeyboardEvent) { + if (disabled) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } else if (e.key === 'Escape') { + isOpen = false; + } else if (e.key === 'ArrowDown' && isOpen) { + e.preventDefault(); + const currentIndex = options.findIndex(o => o.value === value); + const nextIndex = Math.min(currentIndex + 1, options.length - 1); + if (!options[nextIndex].disabled) { + value = options[nextIndex].value; + } + } else if (e.key === 'ArrowUp' && isOpen) { + e.preventDefault(); + const currentIndex = options.findIndex(o => o.value === value); + const prevIndex = Math.max(currentIndex - 1, 0); + if (!options[prevIndex].disabled) { + value = options[prevIndex].value; + } + } + } + + function handleClickOutside(e: MouseEvent) { + if (containerRef && !containerRef.contains(e.target as Node)) { + isOpen = false; + } + } + + $effect(() => { + if (isOpen) { + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + } + }); +</script> + +<div + bind:this={containerRef} + class="relative {className}" +> + <!-- Trigger Button --> + <button + type="button" + onclick={toggle} + onkeydown={handleKeydown} + {disabled} + class="w-full flex items-center justify-between gap-2 px-3 py-2 pr-8 text-left + bg-zinc-900 border border-zinc-700 rounded-md text-sm text-zinc-200 + hover:border-zinc-600 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 + transition-colors cursor-pointer outline-none + disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-700" + > + <span class="truncate {!selectedOption ? 'text-zinc-500' : ''}"> + {selectedOption?.label || placeholder} + </span> + <ChevronDown + size={14} + class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}" + /> + </button> + + <!-- Dropdown Menu --> + {#if isOpen} + <div + class="absolute z-50 w-full mt-1 py-1 bg-zinc-900 border border-zinc-700 rounded-md shadow-xl + max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150" + > + {#each options as option} + <button + type="button" + onclick={() => select(option)} + disabled={option.disabled} + class="w-full flex items-center justify-between px-3 py-2 text-sm text-left + transition-colors outline-none + {option.value === value + ? 'bg-indigo-600 text-white' + : 'text-zinc-300 hover:bg-zinc-800'} + {option.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}" + > + <span class="truncate">{option.label}</span> + {#if option.value === value} + <Check size={14} class="shrink-0 ml-2" /> + {/if} + </button> + {/each} + </div> + {/if} +</div> diff --git a/ui/src/components/HomeView.svelte b/ui/src/components/HomeView.svelte index e876c14..7bb7e44 100644 --- a/ui/src/components/HomeView.svelte +++ b/ui/src/components/HomeView.svelte @@ -1,26 +1,205 @@ <script lang="ts"> - // No script needed currently, just static markup mostly + import { onMount } from 'svelte'; + import { gameState } from '../stores/game.svelte'; + import { releasesState } from '../stores/releases.svelte'; + import { Calendar, ExternalLink } from 'lucide-svelte'; + + type Props = { + mouseX: number; + mouseY: number; + }; + let { mouseX = 0, mouseY = 0 }: Props = $props(); + + onMount(() => { + releasesState.loadReleases(); + }); + + function formatDate(dateString: string) { + return new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } + + function escapeHtml(unsafe: string) { + return unsafe + .replace(/&/g, "&") + .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/HsiangNianian/DropOut/commit/${hash}" target="_blank" class="text-emerald-500 hover:underline font-mono bg-emerald-500/10 px-1 rounded text-[10px] py-0.5 transition-colors border border-emerald-500/20 hover:border-emerald-500/50">${hash.substring(0, 7)}</a>`; + }); + + // Auto-link users (@user) + processed = processed.replace(/@([a-zA-Z0-9-]+)/g, '<a href="https://github.com/$1" target="_blank" class="text-zinc-300 hover:text-white hover:underline font-medium">@$1</a>'); + + return processed.split('\n').map(line => { + line = line.trim(); + + // Formatting helper + const formatLine = (text: string) => text + .replace(/\*\*(.*?)\*\*/g, '<strong class="text-zinc-200">$1</strong>') + .replace(/`([^`]+)`/g, '<code class="bg-zinc-800 px-1 py-0.5 rounded text-xs text-zinc-300 font-mono border border-white/5 break-all whitespace-normal">$1</code>') + .replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" class="text-indigo-400 hover:text-indigo-300 hover:underline decoration-indigo-400/30 break-all">$1</a>'); + + // Lists + if (line.startsWith('- ') || line.startsWith('* ')) { + return `<li class="ml-4 list-disc marker:text-zinc-600 mb-1 pl-1 text-zinc-400">${formatLine(line.substring(2))}</li>`; + } + + // Headers + if (line.startsWith('##')) { + return `<h3 class="text-sm font-bold mt-6 mb-3 text-zinc-100 flex items-center gap-2 border-b border-white/5 pb-2 uppercase tracking-wide">${line.replace(/^#+\s+/, '')}</h3>`; + } + if (line.startsWith('#')) { + return `<h3 class="text-base font-bold mt-6 mb-3 text-white">${line.replace(/^#+\s+/, '')}</h3>`; + } + + // Blockquotes + if (line.startsWith('> ')) { + return `<blockquote class="border-l-2 border-zinc-700 pl-4 py-1 my-2 text-zinc-500 italic bg-white/5 rounded-r-sm">${formatLine(line.substring(2))}</blockquote>`; + } + + // Empty lines + if (line === '') return '<div class="h-2"></div>'; + + // Paragraphs + return `<p class="mb-1.5 leading-relaxed">${formatLine(line)}</p>`; + }).join(''); + } </script> -<!-- Background Image - Using gradient fallback --> -<div - class="absolute inset-0 z-0 opacity-60 bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950 transition-transform duration-[10s] ease-linear group-hover:scale-105" -></div> -<div - class="absolute inset-0 z-0 bg-gradient-to-t from-zinc-900 via-transparent to-black/50" -></div> - -<div class="absolute bottom-24 left-8 z-10 p-4"> - <h1 - class="text-6xl font-black mb-2 tracking-tight text-white drop-shadow-lg" - > - MINECRAFT - </h1> - <div class="flex items-center gap-2 text-zinc-300"> - <span - class="bg-zinc-800 text-xs px-2 py-1 rounded border border-zinc-600" - >JAVA EDITION</span - > - <span class="text-lg">Release 1.20.4</span> +<div class="absolute inset-0 z-0 overflow-hidden pointer-events-none"> + <!-- Fixed Background --> + <div class="absolute inset-0 bg-gradient-to-t from-[#09090b] via-[#09090b]/60 to-transparent"></div> +</div> + +<!-- Scrollable Container --> +<div class="relative z-10 h-full {releasesState.isLoading ? 'overflow-hidden' : 'overflow-y-auto custom-scrollbar scroll-smooth'}"> + + <!-- Hero Section (Full Height) --> + <div class="min-h-full flex flex-col justify-end p-12 pb-32"> + <!-- 3D Floating Hero Text --> + <div + class="transition-transform duration-200 ease-out origin-bottom-left" + style:transform={`perspective(1000px) rotateX(${mouseY * -1}deg) rotateY(${mouseX * 1}deg)`} + > + <div class="flex items-center gap-3 mb-6"> + <div class="h-px w-12 bg-white/50"></div> + <span class="text-xs font-mono font-bold tracking-[0.2em] text-white/50 uppercase">Launcher Active</span> + </div> + + <h1 + class="text-8xl font-black tracking-tighter text-white mb-6 leading-none" + > + MINECRAFT + </h1> + + <div class="flex items-center gap-4"> + <div + class="bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-sm text-xs font-bold uppercase tracking-widest text-white shadow-sm" + > + Java Edition + </div> + <div class="h-4 w-px bg-white/20"></div> + <div class="text-xl font-light text-zinc-400"> + Latest Release <span class="text-white font-medium">{gameState.latestRelease?.id || '...'}</span> + </div> + </div> + </div> + + <!-- Action Area --> + <div class="mt-8 flex gap-4"> + <div class="text-zinc-500 text-sm font-mono"> + > Ready to launch session. + </div> + </div> + + <!-- Scroll Hint --> + {#if !releasesState.isLoading && releasesState.releases.length > 0} + <div class="absolute bottom-10 left-12 animate-bounce text-zinc-600 flex flex-col items-center gap-2 w-fit opacity-50 hover:opacity-100 transition-opacity"> + <span class="text-[10px] font-mono uppercase tracking-widest">Scroll for Updates</span> + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg> + </div> + {/if} + </div> + + <!-- Changelog / Updates Section --> + <div class="bg-[#09090b] relative z-20 px-12 pb-24 pt-12 border-t border-white/5 min-h-[50vh]"> + <div class="max-w-4xl"> + <h2 class="text-2xl font-bold text-white mb-10 flex items-center gap-3"> + <span class="w-1.5 h-8 bg-emerald-500 rounded-sm"></span> + LATEST UPDATES + </h2> + + {#if releasesState.isLoading} + <div class="flex flex-col gap-8"> + {#each Array(3) as _} + <div class="h-48 bg-white/5 rounded-sm animate-pulse border border-white/5"></div> + {/each} + </div> + {:else if releasesState.error} + <div class="p-6 border border-red-500/20 bg-red-500/10 text-red-400 rounded-sm"> + Failed to load updates: {releasesState.error} + </div> + {:else if releasesState.releases.length === 0} + <div class="text-zinc-500 italic">No releases found.</div> + {:else} + <div class="space-y-12"> + {#each releasesState.releases as release} + <div class="group relative pl-8 border-l border-white/10 pb-4 last:pb-0 last:border-l-0"> + <!-- Timeline Dot --> + <div class="absolute -left-[5px] top-1.5 w-2.5 h-2.5 rounded-full bg-zinc-800 border border-zinc-600 group-hover:bg-emerald-500 group-hover:border-emerald-400 transition-colors"></div> + + <div class="flex items-baseline gap-4 mb-3"> + <h3 class="text-xl font-bold text-white group-hover:text-emerald-400 transition-colors"> + {release.name || release.tag_name} + </h3> + <div class="text-xs font-mono text-zinc-500 flex items-center gap-2"> + <Calendar size={12} /> + {formatDate(release.published_at)} + </div> + </div> + + <div class="bg-zinc-900/50 border border-white/5 hover:border-white/10 rounded-sm p-6 text-zinc-400 text-sm leading-relaxed transition-colors overflow-hidden"> + <div class="prose prose-invert prose-sm max-w-none prose-p:text-zinc-400 prose-headings:text-zinc-200 prose-ul:my-2 prose-li:my-0 break-words whitespace-normal"> + {@html formatBody(release.body)} + </div> + </div> + + <a href={release.html_url} target="_blank" class="inline-flex items-center gap-2 mt-3 text-[10px] font-bold uppercase tracking-wider text-zinc-600 hover:text-white transition-colors"> + View full changelog on GitHub <ExternalLink size={10} /> + </a> + </div> + {/each} + </div> + {/if} + </div> </div> </div> diff --git a/ui/src/components/LoginModal.svelte b/ui/src/components/LoginModal.svelte index f1ac0d5..1886cd9 100644 --- a/ui/src/components/LoginModal.svelte +++ b/ui/src/components/LoginModal.svelte @@ -9,16 +9,16 @@ {#if authState.isLoginModalOpen} <div - class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4" + class="fixed inset-0 z-[60] flex items-center justify-center bg-white/80 dark:bg-black/80 backdrop-blur-sm p-4" > <div - class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200" + class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200" > <div class="flex justify-between items-center mb-6"> - <h2 class="text-2xl font-bold text-white">Login</h2> + <h2 class="text-2xl font-bold text-gray-900 dark:text-white">Login</h2> <button onclick={() => authState.closeLoginModal()} - class="text-zinc-500 hover:text-white transition group" + class="text-zinc-500 hover:text-black dark:hover:text-white transition group" > ✕ </button> @@ -28,7 +28,7 @@ <div class="space-y-4"> <button onclick={() => authState.startMicrosoftLogin()} - class="w-full flex items-center justify-center gap-3 bg-[#2F2F2F] hover:bg-[#3F3F3F] text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-500 transition-all group" + class="w-full flex items-center justify-center gap-3 bg-gray-100 hover:bg-gray-200 dark:bg-[#2F2F2F] dark:hover:bg-[#3F3F3F] text-gray-900 dark:text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-400 dark:hover:border-zinc-500 transition-all group" > <!-- Microsoft Logo SVG --> <svg @@ -49,10 +49,10 @@ <div class="relative py-2"> <div class="absolute inset-0 flex items-center"> - <div class="w-full border-t border-zinc-700"></div> + <div class="w-full border-t border-zinc-200 dark:border-zinc-700"></div> </div> <div class="relative flex justify-center text-xs uppercase"> - <span class="bg-zinc-900 px-2 text-zinc-500">OR</span> + <span class="bg-white dark:bg-zinc-900 px-2 text-zinc-400 dark:text-zinc-500">OR</span> </div> </div> @@ -61,12 +61,12 @@ type="text" bind:value={authState.offlineUsername} placeholder="Offline Username" - class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-white focus:border-indigo-500 outline-none" + class="w-full bg-gray-50 border-zinc-200 dark:bg-zinc-950 dark:border-zinc-700 rounded p-3 text-gray-900 dark:text-white focus:border-indigo-500 outline-none" onkeydown={(e) => e.key === "Enter" && authState.performOfflineLogin()} /> <button onclick={() => authState.performOfflineLogin()} - class="w-full bg-zinc-800 hover:bg-zinc-700 text-zinc-300 p-3 rounded font-medium transition-colors" + class="w-full bg-gray-200 hover:bg-gray-300 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 p-3 rounded font-medium transition-colors" > Offline Login </button> @@ -80,18 +80,18 @@ </div> {:else if authState.deviceCodeData} <div class="space-y-4"> - <p class="text-sm text-zinc-400">1. Go to this URL:</p> + <p class="text-sm text-gray-500 dark:text-zinc-400">1. Go to this URL:</p> <button onclick={() => authState.deviceCodeData && openLink(authState.deviceCodeData.verification_uri)} - class="text-indigo-400 hover:text-indigo-300 underline break-all font-mono text-sm" + class="text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 underline break-all font-mono text-sm" > {authState.deviceCodeData.verification_uri} </button> - <p class="text-sm text-zinc-400 mt-2">2. Enter this code:</p> + <p class="text-sm text-gray-500 dark:text-zinc-400 mt-2">2. Enter this code:</p> <div - class="bg-zinc-950 p-4 rounded border border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors" + class="bg-gray-50 dark:bg-zinc-950 p-4 rounded border border-zinc-200 dark:border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors dark:text-white text-gray-900" role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && navigator.clipboard.writeText(authState.deviceCodeData?.user_code || "")} @@ -106,8 +106,8 @@ <div class="pt-6 space-y-3"> <div class="flex flex-col items-center gap-3"> - <div class="animate-spin rounded-full h-6 w-6 border-2 border-zinc-600 border-t-indigo-500"></div> - <span class="text-sm text-zinc-400 font-medium break-all text-center">{authState.msLoginStatus}</span> + <div class="animate-spin rounded-full h-6 w-6 border-2 border-zinc-300 dark:border-zinc-600 border-t-indigo-500"></div> + <span class="text-sm text-gray-600 dark:text-zinc-400 font-medium break-all text-center">{authState.msLoginStatus}</span> </div> <p class="text-xs text-zinc-600">This window will update automatically.</p> </div> diff --git a/ui/src/components/ModLoaderSelector.svelte b/ui/src/components/ModLoaderSelector.svelte new file mode 100644 index 0000000..cb949c5 --- /dev/null +++ b/ui/src/components/ModLoaderSelector.svelte @@ -0,0 +1,332 @@ +<script lang="ts"> + import { invoke } from "@tauri-apps/api/core"; + import type { + FabricGameVersion, + FabricLoaderVersion, + ForgeVersion, + ModLoaderType, + } from "../types"; + import { Loader2, Download, AlertCircle, Check, ChevronDown } from 'lucide-svelte'; + + interface Props { + selectedGameVersion: string; + onInstall: (versionId: string) => void; + } + + let { selectedGameVersion, onInstall }: Props = $props(); + + // State + let selectedLoader = $state<ModLoaderType>("vanilla"); + let isLoading = $state(false); + let error = $state<string | null>(null); + + // Fabric state + let fabricLoaders = $state<FabricLoaderVersion[]>([]); + let selectedFabricLoader = $state(""); + let isFabricDropdownOpen = $state(false); + + // Forge state + let forgeVersions = $state<ForgeVersion[]>([]); + let selectedForgeVersion = $state(""); + let isForgeDropdownOpen = $state(false); + + let fabricDropdownRef = $state<HTMLDivElement | null>(null); + let forgeDropdownRef = $state<HTMLDivElement | null>(null); + + // Load mod loader versions when game version changes + $effect(() => { + if (selectedGameVersion && selectedLoader !== "vanilla") { + loadModLoaderVersions(); + } + }); + + async function loadModLoaderVersions() { + isLoading = true; + error = null; + + try { + if (selectedLoader === "fabric") { + const loaders = await invoke<any[]>("get_fabric_loaders_for_version", { + gameVersion: selectedGameVersion, + }); + fabricLoaders = loaders.map((l) => l.loader); + if (fabricLoaders.length > 0) { + // Select first stable version or first available + const stable = fabricLoaders.find((l) => l.stable); + selectedFabricLoader = stable?.version || fabricLoaders[0].version; + } + } else if (selectedLoader === "forge") { + forgeVersions = await invoke<ForgeVersion[]>( + "get_forge_versions_for_game", + { + gameVersion: selectedGameVersion, + } + ); + if (forgeVersions.length > 0) { + // Select recommended version first, then latest + const recommended = forgeVersions.find((v) => v.recommended); + const latest = forgeVersions.find((v) => v.latest); + selectedForgeVersion = + recommended?.version || latest?.version || forgeVersions[0].version; + } + } + } catch (e) { + error = `Failed to load ${selectedLoader} versions: ${e}`; + console.error(e); + } finally { + isLoading = false; + } + } + + async function installModLoader() { + if (!selectedGameVersion) { + error = "Please select a Minecraft version first"; + return; + } + + isLoading = true; + error = null; + + try { + if (selectedLoader === "fabric" && selectedFabricLoader) { + const result = await invoke<any>("install_fabric", { + gameVersion: selectedGameVersion, + loaderVersion: selectedFabricLoader, + }); + onInstall(result.id); + } else if (selectedLoader === "forge" && selectedForgeVersion) { + const result = await invoke<any>("install_forge", { + gameVersion: selectedGameVersion, + forgeVersion: selectedForgeVersion, + }); + onInstall(result.id); + } + } catch (e) { + error = `Failed to install ${selectedLoader}: ${e}`; + console.error(e); + } finally { + isLoading = false; + } + } + + function onLoaderChange(loader: ModLoaderType) { + selectedLoader = loader; + error = null; + if (loader !== "vanilla" && selectedGameVersion) { + loadModLoaderVersions(); + } + } + + function handleFabricClickOutside(e: MouseEvent) { + if (fabricDropdownRef && !fabricDropdownRef.contains(e.target as Node)) { + isFabricDropdownOpen = false; + } + } + + function handleForgeClickOutside(e: MouseEvent) { + if (forgeDropdownRef && !forgeDropdownRef.contains(e.target as Node)) { + isForgeDropdownOpen = false; + } + } + + $effect(() => { + if (isFabricDropdownOpen) { + document.addEventListener('click', handleFabricClickOutside); + return () => document.removeEventListener('click', handleFabricClickOutside); + } + }); + + $effect(() => { + if (isForgeDropdownOpen) { + document.addEventListener('click', handleForgeClickOutside); + return () => document.removeEventListener('click', handleForgeClickOutside); + } + }); + + let selectedFabricLabel = $derived( + fabricLoaders.find(l => l.version === selectedFabricLoader) + ? `${selectedFabricLoader}${fabricLoaders.find(l => l.version === selectedFabricLoader)?.stable ? ' (stable)' : ''}` + : selectedFabricLoader || 'Select version' + ); + + let selectedForgeLabel = $derived( + forgeVersions.find(v => v.version === selectedForgeVersion) + ? `${selectedForgeVersion}${forgeVersions.find(v => v.version === selectedForgeVersion)?.recommended ? ' (Recommended)' : ''}` + : selectedForgeVersion || 'Select version' + ); +</script> + +<div class="space-y-4"> + <div class="flex items-center justify-between"> + <h3 class="text-xs font-bold uppercase tracking-widest text-zinc-500">Loader Type</h3> + </div> + + <!-- Loader Type Tabs - Simple Clean Style --> + <div class="flex p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-sm border border-zinc-200 dark:border-white/5"> + {#each ['vanilla', 'fabric', 'forge'] as loader} + <button + class="flex-1 px-3 py-2 rounded-sm text-sm font-medium transition-all duration-200 capitalize + {selectedLoader === loader + ? 'bg-white dark:bg-white/10 text-black dark:text-white shadow-sm' + : 'text-zinc-500 dark:text-zinc-500 hover:text-black dark:hover:text-white'}" + onclick={() => onLoaderChange(loader as ModLoaderType)} + > + {loader} + </button> + {/each} + </div> + + <!-- Content Area --> + <div class="min-h-[100px] flex flex-col justify-center"> + {#if selectedLoader === "vanilla"} + <div class="text-center p-6 border border-dashed border-zinc-200 dark:border-white/10 rounded-sm text-zinc-500 text-sm"> + Standard Minecraft experience. No modifications. + </div> + + {:else if !selectedGameVersion} + <div class="flex items-center gap-3 p-4 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 text-amber-700 dark:text-amber-200 rounded-sm text-sm"> + <AlertCircle size={16} /> + <span>Please select a base Minecraft version first.</span> + </div> + + {:else if isLoading} + <div class="flex flex-col items-center gap-3 text-sm text-zinc-500 py-6"> + <Loader2 class="animate-spin" size={20} /> + <span>Fetching {selectedLoader} manifest...</span> + </div> + + {:else if error} + <div class="p-4 bg-red-50 border border-red-200 text-red-700 dark:bg-red-500/10 dark:border-red-500/20 dark:text-red-300 rounded-sm text-sm break-words"> + {error} + </div> + + {:else if selectedLoader === "fabric"} + <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300"> + <div> + <label for="fabric-loader-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2" + >Loader Version</label + > + <!-- Custom Fabric Dropdown --> + <div class="relative" bind:this={fabricDropdownRef}> + <button + type="button" + onclick={() => isFabricDropdownOpen = !isFabricDropdownOpen} + class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left + bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md + text-sm text-gray-900 dark:text-white + hover:border-zinc-400 dark:hover:border-zinc-600 + focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 + transition-colors cursor-pointer outline-none" + > + <span class="truncate">{selectedFabricLabel}</span> + <ChevronDown + size={14} + class="shrink-0 text-zinc-400 dark:text-zinc-500 transition-transform duration-200 {isFabricDropdownOpen ? 'rotate-180' : ''}" + /> + </button> + + {#if isFabricDropdownOpen} + <div + class="absolute z-50 w-full mt-1 py-1 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md shadow-xl + max-h-48 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150" + > + {#each fabricLoaders as loader} + <button + type="button" + onclick={() => { selectedFabricLoader = loader.version; isFabricDropdownOpen = false; }} + class="w-full flex items-center justify-between px-3 py-2 text-sm text-left + transition-colors outline-none cursor-pointer + {loader.version === selectedFabricLoader + ? 'bg-indigo-600 text-white' + : 'text-gray-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'}" + > + <span class="truncate">{loader.version} {loader.stable ? "(stable)" : ""}</span> + {#if loader.version === selectedFabricLoader} + <Check size={14} class="shrink-0 ml-2" /> + {/if} + </button> + {/each} + </div> + {/if} + </div> + </div> + + <button + class="w-full bg-black dark:bg-white text-white dark:text-black py-2.5 px-4 rounded-sm font-bold text-sm transition-all hover:opacity-90 flex items-center justify-center gap-2" + onclick={installModLoader} + disabled={isLoading || !selectedFabricLoader} + > + <Download size={16} /> + Install Fabric + </button> + </div> + + {:else if selectedLoader === "forge"} + <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300"> + {#if forgeVersions.length === 0} + <div class="text-center p-4 text-sm text-zinc-500 italic"> + No Forge versions available for {selectedGameVersion} + </div> + {:else} + <div> + <label for="forge-version-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2" + >Forge Version</label + > + <!-- Custom Forge Dropdown --> + <div class="relative" bind:this={forgeDropdownRef}> + <button + type="button" + onclick={() => isForgeDropdownOpen = !isForgeDropdownOpen} + class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left + bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md + text-sm text-gray-900 dark:text-white + hover:border-zinc-400 dark:hover:border-zinc-600 + focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 + transition-colors cursor-pointer outline-none" + > + <span class="truncate">{selectedForgeLabel}</span> + <ChevronDown + size={14} + class="shrink-0 text-zinc-400 dark:text-zinc-500 transition-transform duration-200 {isForgeDropdownOpen ? 'rotate-180' : ''}" + /> + </button> + + {#if isForgeDropdownOpen} + <div + class="absolute z-50 w-full mt-1 py-1 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md shadow-xl + max-h-48 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150" + > + {#each forgeVersions as version} + <button + type="button" + onclick={() => { selectedForgeVersion = version.version; isForgeDropdownOpen = false; }} + class="w-full flex items-center justify-between px-3 py-2 text-sm text-left + transition-colors outline-none cursor-pointer + {version.version === selectedForgeVersion + ? 'bg-indigo-600 text-white' + : 'text-gray-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'}" + > + <span class="truncate">{version.version} {version.recommended ? "(Recommended)" : ""}</span> + {#if version.version === selectedForgeVersion} + <Check size={14} class="shrink-0 ml-2" /> + {/if} + </button> + {/each} + </div> + {/if} + </div> + </div> + + <button + class="w-full bg-black dark:bg-white text-white dark:text-black py-2.5 px-4 rounded-sm font-bold text-sm transition-all hover:opacity-90 flex items-center justify-center gap-2" + onclick={installModLoader} + disabled={isLoading || !selectedForgeVersion} + > + <Download size={16} /> + Install Forge + </button> + {/if} + </div> + {/if} + </div> +</div> diff --git a/ui/src/components/ParticleBackground.svelte b/ui/src/components/ParticleBackground.svelte new file mode 100644 index 0000000..080f1f2 --- /dev/null +++ b/ui/src/components/ParticleBackground.svelte @@ -0,0 +1,57 @@ +<script lang="ts"> + import { onMount, onDestroy } from "svelte"; + import { ConstellationEffect } from "../lib/effects/ConstellationEffect"; + import { SaturnEffect } from "../lib/effects/SaturnEffect"; + import { settingsState } from "../stores/settings.svelte"; + + let canvas: HTMLCanvasElement; + let activeEffect: any; + + function loadEffect() { + if (activeEffect) { + activeEffect.destroy(); + } + + if (!canvas) return; + + if (settingsState.settings.active_effect === "saturn") { + activeEffect = new SaturnEffect(canvas); + } else { + activeEffect = new ConstellationEffect(canvas); + } + + // Ensure correct size immediately + activeEffect.resize(window.innerWidth, window.innerHeight); + } + + $effect(() => { + const _ = settingsState.settings.active_effect; + if (canvas) { + loadEffect(); + } + }); + + onMount(() => { + const resizeObserver = new ResizeObserver(() => { + if (canvas && activeEffect) { + activeEffect.resize(window.innerWidth, window.innerHeight); + } + }); + + resizeObserver.observe(document.body); + + return () => { + resizeObserver.disconnect(); + if (activeEffect) activeEffect.destroy(); + }; + }); + + onDestroy(() => { + if (activeEffect) activeEffect.destroy(); + }); +</script> + +<canvas + bind:this={canvas} + class="absolute inset-0 z-0 pointer-events-none" +></canvas> diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte index 801970b..76d441b 100644 --- a/ui/src/components/SettingsView.svelte +++ b/ui/src/components/SettingsView.svelte @@ -1,153 +1,685 @@ <script lang="ts"> + import { open } from "@tauri-apps/plugin-dialog"; import { settingsState } from "../stores/settings.svelte"; + import CustomSelect from "./CustomSelect.svelte"; + + // Use convertFileSrc directly from settingsState.backgroundUrl for cleaner approach + // or use the imported one if passing raw path. + import { convertFileSrc } from "@tauri-apps/api/core"; + + const effectOptions = [ + { value: "saturn", label: "Saturn" }, + { value: "constellation", label: "Network (Constellation)" } + ]; + + const logServiceOptions = [ + { value: "paste.rs", label: "paste.rs (Free, No Account)" }, + { value: "pastebin.com", label: "pastebin.com (Requires API Key)" } + ]; + + async function selectBackground() { + try { + const selected = await open({ + multiple: false, + filters: [ + { + name: "Images", + extensions: ["png", "jpg", "jpeg", "webp", "gif"], + }, + ], + }); + + if (selected && typeof selected === "string") { + settingsState.settings.custom_background_path = selected; + settingsState.saveSettings(); + } + } catch (e) { + console.error("Failed to select background:", e); + } + } + + function clearBackground() { + settingsState.settings.custom_background_path = undefined; + settingsState.saveSettings(); + } </script> -<div class="p-8 bg-zinc-900 h-full overflow-y-auto"> - <h2 class="text-3xl font-bold mb-8">Settings</h2> +<div class="h-full flex flex-col p-6 overflow-hidden"> + <div class="flex items-center justify-between mb-6"> + <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">Settings</h2> + </div> - <div class="space-y-6 max-w-2xl"> - <!-- Java Path --> - <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700"> - <label - for="java-path" - class="block text-sm font-bold text-zinc-400 mb-2 uppercase tracking-wide" - >Java Executable Path</label - > - <div class="flex gap-2"> - <input - id="java-path" - bind:value={settingsState.settings.java_path} - type="text" - class="bg-zinc-950 text-white flex-1 p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none font-mono text-sm" - placeholder="e.g. java, /usr/bin/java" - /> - <button - onclick={() => settingsState.detectJava()} - disabled={settingsState.isDetectingJava} - class="bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-white px-4 py-2 rounded transition-colors whitespace-nowrap" - > - {settingsState.isDetectingJava ? "Detecting..." : "Auto Detect"} - </button> + <div class="flex-1 overflow-y-auto pr-2 space-y-6 custom-scrollbar pb-10"> + + <!-- Appearance / Background --> + <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm"> + <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-6 flex items-center gap-2"> + Appearance + </h3> + + <div class="space-y-4"> + <div> + <label class="block text-sm font-medium dark:text-white/70 text-black/70 mb-3">Custom Background Image</label> + + <div class="flex items-center gap-6"> + <!-- Preview --> + <div class="w-40 h-24 rounded-xl overflow-hidden dark:bg-black/50 bg-gray-200 border dark:border-white/10 border-black/10 relative group shadow-lg"> + {#if settingsState.settings.custom_background_path} + <img + src={convertFileSrc(settingsState.settings.custom_background_path)} + alt="Background Preview" + class="w-full h-full object-cover" + onerror={(e) => { + console.error("Failed to load image:", settingsState.settings.custom_background_path, e); + // e.currentTarget.style.display = 'none'; + }} + /> + {:else} + <div class="w-full h-full bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950 opacity-100"></div> + <div class="absolute inset-0 flex items-center justify-center text-[10px] text-white/50 bg-black/20 ">Default Gradient</div> + {/if} + </div> + + <!-- Actions --> + <div class="flex flex-col gap-2"> + <button + onclick={selectBackground} + class="dark:bg-white/10 dark:hover:bg-white/20 bg-black/5 hover:bg-black/10 dark:text-white text-black px-4 py-2 rounded-lg text-sm transition-colors border dark:border-white/5 border-black/5" + > + Select Image + </button> + + {#if settingsState.settings.custom_background_path} + <button + onclick={clearBackground} + class="text-red-400 hover:text-red-300 text-sm px-4 py-1 text-left transition-colors" + > + Reset to Default + </button> + {/if} + </div> + </div> + <p class="text-xs dark:text-white/30 text-black/40 mt-3"> + Select an image from your computer to replace the default gradient background. + Supported formats: PNG, JPG, WEBP, GIF. + </p> + </div> + + <!-- Visual Settings --> + <div class="pt-4 border-t dark:border-white/5 border-black/5 space-y-4"> + <div class="flex items-center justify-between"> + <div> + <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="visual-effects-label">Visual Effects</h4> + <p class="text-xs dark:text-white/40 text-black/50 mt-1">Enable particle effects and animated gradients. (Default: On)</p> + </div> + <button + aria-labelledby="visual-effects-label" + onclick={() => { settingsState.settings.enable_visual_effects = !settingsState.settings.enable_visual_effects; settingsState.saveSettings(); }} + class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.enable_visual_effects ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}" + > + <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.enable_visual_effects ? 'translate-x-5' : 'translate-x-0'}"></div> + </button> + </div> + + {#if settingsState.settings.enable_visual_effects} + <div class="flex items-center justify-between pl-2 border-l-2 dark:border-white/5 border-black/5 ml-1"> + <div> + <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="theme-effect-label">Theme Effect</h4> + <p class="text-xs dark:text-white/40 text-black/50 mt-1">Select the active visual theme.</p> + </div> + <CustomSelect + options={effectOptions} + bind:value={settingsState.settings.active_effect} + onchange={() => settingsState.saveSettings()} + class="w-52" + /> + </div> + {/if} + + <div class="flex items-center justify-between"> + <div> + <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="gpu-acceleration-label">GPU Acceleration</h4> + <p class="text-xs dark:text-white/40 text-black/50 mt-1">Enable GPU acceleration for the interface. (Default: Off, Requires Restart)</p> + </div> + <button + aria-labelledby="gpu-acceleration-label" + onclick={() => { settingsState.settings.enable_gpu_acceleration = !settingsState.settings.enable_gpu_acceleration; settingsState.saveSettings(); }} + class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.enable_gpu_acceleration ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}" + > + <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.enable_gpu_acceleration ? 'translate-x-5' : 'translate-x-0'}"></div> + </button> + </div> + + <!-- Color Theme Switcher --> + <div class="flex items-center justify-between pt-4 border-t dark:border-white/5 border-black/5 opacity-50 cursor-not-allowed"> + <div> + <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="color-theme-label">Color Theme</h4> + <p class="text-xs dark:text-white/40 text-black/50 mt-1">Interface color mode. (Locked to Dark)</p> + </div> + <div class="flex items-center bg-black/5 dark:bg-white/5 rounded-lg p-1 pointer-events-none"> + <button + disabled + class="px-3 py-1 rounded-md text-xs font-medium transition-all text-gray-500 dark:text-gray-600" + > + Light + </button> + <button + disabled + class="px-3 py-1 rounded-md text-xs font-medium transition-all bg-indigo-500 text-white shadow-sm" + > + Dark + </button> + </div> + </div> + </div> </div> + </div> + + <!-- Java Path --> + <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm"> + <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2"> + Java Environment + </h3> + <div class="space-y-4"> + <div> + <label for="java-path" class="block text-sm font-medium text-white/70 mb-2">Java Executable Path</label> + <div class="flex gap-2"> + <input + id="java-path" + bind:value={settingsState.settings.java_path} + type="text" + class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors" + placeholder="e.g. java, /usr/bin/java" + /> + <button + onclick={() => settingsState.detectJava()} + disabled={settingsState.isDetectingJava} + class="bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white px-4 py-2 rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium" + > + {settingsState.isDetectingJava ? "Detecting..." : "Auto Detect"} + </button> + <button + onclick={() => settingsState.openJavaDownloadModal()} + class="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-xl transition-colors whitespace-nowrap text-sm font-medium" + > + Download Java + </button> + </div> + </div> {#if settingsState.javaInstallations.length > 0} <div class="mt-4 space-y-2"> - <p class="text-xs text-zinc-400 uppercase font-bold">Detected Java Installations:</p> + <p class="text-[10px] text-white/30 uppercase font-bold tracking-wider">Detected Installations</p> {#each settingsState.javaInstallations as java} <button onclick={() => settingsState.selectJava(java.path)} - class="w-full text-left p-3 bg-zinc-950 rounded border transition-colors {settingsState.settings.java_path === java.path ? 'border-indigo-500 bg-indigo-950/30' : 'border-zinc-700 hover:border-zinc-500'}" + class="w-full text-left p-3 rounded-lg border transition-all duration-200 group + {settingsState.settings.java_path === java.path + ? 'bg-indigo-500/20 border-indigo-500/30' + : 'bg-black/20 border-white/5 hover:bg-white/5 hover:border-white/10'}" > <div class="flex justify-between items-center"> <div> - <span class="text-white font-mono text-sm">{java.version}</span> - <span class="text-zinc-500 text-xs ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span> + <span class="text-white font-mono text-xs font-bold">{java.version}</span> + <span class="text-white/40 text-[10px] ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span> </div> {#if settingsState.settings.java_path === java.path} - <span class="text-indigo-400 text-xs">Selected</span> + <span class="text-indigo-300 text-[10px] font-bold uppercase tracking-wider">Selected</span> {/if} </div> - <div class="text-zinc-500 text-xs font-mono truncate mt-1">{java.path}</div> + <div class="text-white/30 text-[10px] font-mono truncate mt-1 group-hover:text-white/50">{java.path}</div> </button> {/each} </div> {/if} - - <p class="text-xs text-zinc-500 mt-2"> - The command or path to the Java Runtime Environment. Click "Auto Detect" to find installed Java versions. - </p> + </div> </div> <!-- Memory --> - <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700"> - <h3 - class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide" - >Memory Allocation (RAM)</h3> - + <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm"> + <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2"> + Memory Allocation (RAM) + </h3> <div class="grid grid-cols-2 gap-6"> <div> - <label for="min-memory" class="block text-xs text-zinc-500 mb-1" - >Minimum (MB)</label - > + <label for="min-memory" class="block text-sm font-medium text-white/70 mb-2">Minimum (MB)</label> <input id="min-memory" bind:value={settingsState.settings.min_memory} type="number" - class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none" + class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors" /> </div> <div> - <label for="max-memory" class="block text-xs text-zinc-500 mb-1" - >Maximum (MB)</label - > + <label for="max-memory" class="block text-sm font-medium text-white/70 mb-2">Maximum (MB)</label> <input id="max-memory" bind:value={settingsState.settings.max_memory} type="number" - class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none" + class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors" /> </div> </div> </div> <!-- Resolution --> - <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700"> - <h3 - class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide" - >Game Window Size</h3> + <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm"> + <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2"> + Game Window Size + </h3> <div class="grid grid-cols-2 gap-6"> <div> - <label for="window-width" class="block text-xs text-zinc-500 mb-1">Width</label> + <label for="window-width" class="block text-sm font-medium text-white/70 mb-2">Width</label> <input id="window-width" bind:value={settingsState.settings.width} type="number" - class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none" + class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors" /> </div> <div> - <label for="window-height" class="block text-xs text-zinc-500 mb-1">Height</label> + <label for="window-height" class="block text-sm font-medium text-white/70 mb-2">Height</label> <input id="window-height" bind:value={settingsState.settings.height} type="number" - class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none" + class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors" /> </div> </div> </div> <!-- Download Settings --> - <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700"> - <h3 - class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide" - >Download Settings</h3> - <div> - <label for="download-threads" class="block text-xs text-zinc-500 mb-1" - >Concurrent Download Threads</label - > - <input - id="download-threads" - bind:value={settingsState.settings.download_threads} - type="number" - min="1" - max="128" - step="1" - class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none" - /> - <p class="text-xs text-zinc-500 mt-2"> - Number of concurrent download threads (1-128). Higher values increase download speed but use more bandwidth and system resources. Default: 32 - </p> - </div> + <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm"> + <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2"> + Network + </h3> + <div> + <label for="download-threads" class="block text-sm font-medium text-white/70 mb-2">Concurrent Download Threads</label> + <input + id="download-threads" + bind:value={settingsState.settings.download_threads} + type="number" + min="1" + max="128" + class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors" + /> + <p class="text-xs text-white/30 mt-2">Higher values usually mean faster downloads but use more CPU/Network.</p> + </div> </div> - <div class="pt-4"> + <!-- Debug / Logs --> + <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm"> + <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2"> + Debug & Logs + </h3> + <div class="space-y-4"> + <div> + <label for="log-service" class="block text-sm font-medium text-white/70 mb-2">Log Upload Service</label> + <CustomSelect + options={logServiceOptions} + bind:value={settingsState.settings.log_upload_service} + class="w-full" + /> + </div> + + {#if settingsState.settings.log_upload_service === 'pastebin.com'} + <div> + <label for="pastebin-key" class="block text-sm font-medium text-white/70 mb-2">Pastebin Dev API Key</label> + <input + id="pastebin-key" + type="password" + bind:value={settingsState.settings.pastebin_api_key} + placeholder="Enter your API Key" + class="dark:bg-zinc-900 bg-white dark:text-white text-black w-full px-4 py-3 rounded-xl border dark:border-zinc-700 border-gray-300 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 outline-none transition-colors placeholder:text-zinc-500" + /> + <p class="text-xs text-white/30 mt-2"> + Get your API key from <a href="https://pastebin.com/doc_api#1" target="_blank" class="text-indigo-400 hover:underline">Pastebin API Documentation</a>. + </p> + </div> + {/if} + </div> + </div> + + <div class="pt-4 flex justify-end"> <button onclick={() => settingsState.saveSettings()} - class="bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-8 rounded shadow-lg transition-transform active:scale-95" + class="bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-500 hover:to-green-500 text-white font-bold py-3 px-8 rounded-xl shadow-lg shadow-emerald-500/20 transition-all hover:scale-105 active:scale-95" > Save Settings </button> </div> </div> </div> + +<!-- Java Download Modal --> +{#if settingsState.showJavaDownloadModal} + <div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70"> + <div class="bg-zinc-900 rounded-2xl border border-white/10 shadow-2xl w-[900px] max-w-[95vw] h-[600px] max-h-[90vh] flex flex-col overflow-hidden"> + <!-- Header --> + <div class="flex items-center justify-between p-5 border-b border-white/10"> + <h3 class="text-xl font-bold text-white">Download Java</h3> + <button + aria-label="Close dialog" + onclick={() => settingsState.closeJavaDownloadModal()} + disabled={settingsState.isDownloadingJava} + class="text-white/40 hover:text-white/80 disabled:opacity-50 transition-colors p-1" + > + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> + </svg> + </button> + </div> + + <!-- Main Content Area --> + <div class="flex flex-1 overflow-hidden"> + <!-- Left Sidebar: Sources --> + <div class="w-40 border-r border-white/10 p-3 flex flex-col gap-1"> + <span class="text-[10px] font-bold uppercase tracking-widest text-white/30 px-2 mb-2">Sources</span> + + <button + disabled + class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm opacity-40 cursor-not-allowed text-white/50" + > + <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center text-[10px]">M</div> + Mojang + </button> + + <button + class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm bg-indigo-500/20 border border-indigo-500/40 text-white" + > + <div class="w-5 h-5 rounded bg-indigo-500 flex items-center justify-center text-[10px] font-bold">A</div> + Adoptium + </button> + + <button + disabled + class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm opacity-40 cursor-not-allowed text-white/50" + > + <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center text-[10px]">Z</div> + Azul Zulu + </button> + </div> + + <!-- Center: Version Selection --> + <div class="flex-1 flex flex-col overflow-hidden"> + <!-- Toolbar --> + <div class="flex items-center gap-3 p-4 border-b border-white/5"> + <!-- Search --> + <div class="relative flex-1 max-w-xs"> + <input + type="text" + bind:value={settingsState.searchQuery} + placeholder="Search versions..." + class="w-full bg-black/30 text-white text-sm px-4 py-2 pl-9 rounded-lg border border-white/10 focus:border-indigo-500/50 outline-none" + /> + <svg class="absolute left-3 top-2.5 w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> + </svg> + </div> + + <!-- Recommended Filter --> + <label class="flex items-center gap-2 text-sm text-white/60 cursor-pointer select-none"> + <input + type="checkbox" + bind:checked={settingsState.showOnlyRecommended} + class="w-4 h-4 rounded border-white/20 bg-black/30 text-indigo-500 focus:ring-indigo-500/30" + /> + LTS Only + </label> + + <!-- Image Type Toggle --> + <div class="flex items-center bg-black/30 rounded-lg p-0.5 border border-white/10"> + <button + onclick={() => settingsState.selectedImageType = "jre"} + class="px-3 py-1.5 rounded-md text-xs font-medium transition-all {settingsState.selectedImageType === 'jre' ? 'bg-indigo-500 text-white shadow' : 'text-white/50 hover:text-white/80'}" + > + JRE + </button> + <button + onclick={() => settingsState.selectedImageType = "jdk"} + class="px-3 py-1.5 rounded-md text-xs font-medium transition-all {settingsState.selectedImageType === 'jdk' ? 'bg-indigo-500 text-white shadow' : 'text-white/50 hover:text-white/80'}" + > + JDK + </button> + </div> + </div> + + <!-- Loading State --> + {#if settingsState.isLoadingCatalog} + <div class="flex-1 flex items-center justify-center text-white/50"> + <div class="flex flex-col items-center gap-3"> + <div class="w-8 h-8 border-2 border-indigo-500/30 border-t-indigo-500 rounded-full animate-spin"></div> + <span class="text-sm">Loading Java versions...</span> + </div> + </div> + {:else if settingsState.catalogError} + <div class="flex-1 flex items-center justify-center text-red-400"> + <div class="flex flex-col items-center gap-3 text-center px-8"> + <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/> + </svg> + <span class="text-sm">{settingsState.catalogError}</span> + <button + onclick={() => settingsState.refreshCatalog()} + class="mt-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-sm text-white transition-colors" + > + Retry + </button> + </div> + </div> + {:else} + <!-- Version List --> + <div class="flex-1 overflow-auto p-4"> + <div class="space-y-2"> + {#each settingsState.availableMajorVersions as version} + {@const isLts = settingsState.javaCatalog?.lts_versions.includes(version)} + {@const isSelected = settingsState.selectedMajorVersion === version} + {@const releaseInfo = settingsState.javaCatalog?.releases.find(r => r.major_version === version && r.image_type === settingsState.selectedImageType)} + {@const isAvailable = releaseInfo?.is_available ?? false} + {@const installStatus = releaseInfo ? settingsState.getInstallStatus(releaseInfo) : 'download'} + + <button + onclick={() => settingsState.selectMajorVersion(version)} + disabled={!isAvailable} + class="w-full flex items-center gap-4 p-3 rounded-xl border transition-all text-left + {isSelected + ? 'bg-indigo-500/20 border-indigo-500/50 ring-2 ring-indigo-500/30' + : isAvailable + ? 'bg-black/20 border-white/10 hover:bg-white/5 hover:border-white/20' + : 'bg-black/10 border-white/5 opacity-40 cursor-not-allowed'}" + > + <!-- Version Number --> + <div class="w-14 text-center"> + <span class="text-xl font-bold {isSelected ? 'text-white' : 'text-white/80'}">{version}</span> + </div> + + <!-- Version Details --> + <div class="flex-1 min-w-0"> + <div class="flex items-center gap-2"> + <span class="text-sm text-white/70 font-mono truncate">{releaseInfo?.version ?? '--'}</span> + {#if isLts} + <span class="px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 text-[9px] font-bold rounded uppercase shrink-0">LTS</span> + {/if} + </div> + {#if releaseInfo} + <div class="text-[10px] text-white/40 truncate mt-0.5"> + {releaseInfo.release_name} • {settingsState.formatBytes(releaseInfo.file_size)} + </div> + {/if} + </div> + + <!-- Install Status Badge --> + <div class="shrink-0"> + {#if installStatus === 'installed'} + <span class="px-2 py-1 bg-emerald-500/20 text-emerald-400 text-[10px] font-bold rounded uppercase">Installed</span> + {:else if isAvailable} + <span class="px-2 py-1 bg-white/10 text-white/50 text-[10px] font-medium rounded">Download</span> + {:else} + <span class="px-2 py-1 bg-red-500/10 text-red-400/60 text-[10px] font-medium rounded">N/A</span> + {/if} + </div> + </button> + {/each} + </div> + </div> + {/if} + </div> + + <!-- Right Sidebar: Details --> + <div class="w-64 border-l border-white/10 flex flex-col"> + <div class="p-4 border-b border-white/5"> + <span class="text-[10px] font-bold uppercase tracking-widest text-white/30">Details</span> + </div> + + {#if settingsState.selectedRelease} + <div class="flex-1 p-4 space-y-4 overflow-auto"> + <div> + <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Version</div> + <div class="text-sm text-white font-mono">{settingsState.selectedRelease.version}</div> + </div> + + <div> + <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Release Name</div> + <div class="text-sm text-white">{settingsState.selectedRelease.release_name}</div> + </div> + + <div> + <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Release Date</div> + <div class="text-sm text-white">{settingsState.formatDate(settingsState.selectedRelease.release_date)}</div> + </div> + + <div> + <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Size</div> + <div class="text-sm text-white">{settingsState.formatBytes(settingsState.selectedRelease.file_size)}</div> + </div> + + <div> + <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Type</div> + <div class="flex items-center gap-2"> + <span class="text-sm text-white uppercase">{settingsState.selectedRelease.image_type}</span> + {#if settingsState.selectedRelease.is_lts} + <span class="px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 text-[9px] font-bold rounded">LTS</span> + {/if} + </div> + </div> + + <div> + <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Architecture</div> + <div class="text-sm text-white">{settingsState.selectedRelease.architecture}</div> + </div> + + {#if !settingsState.selectedRelease.is_available} + <div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg"> + <div class="text-xs text-red-400">Not available for your platform</div> + </div> + {/if} + </div> + {:else} + <div class="flex-1 flex items-center justify-center text-white/30 text-sm p-4 text-center"> + Select a Java version to view details + </div> + {/if} + </div> + </div> + + <!-- Download Progress (MC Style) --> + {#if settingsState.isDownloadingJava && settingsState.downloadProgress} + <div class="border-t border-white/10 p-4 bg-zinc-900/90"> + <div class="flex items-center justify-between mb-2"> + <h3 class="text-white font-bold text-sm">Downloading Java</h3> + <span class="text-xs text-zinc-400">{settingsState.downloadProgress.status}</span> + </div> + + <!-- Progress Bar --> + <div class="mb-2"> + <div class="flex justify-between text-[10px] text-zinc-400 mb-1"> + <span>{settingsState.downloadProgress.file_name}</span> + <span>{Math.round(settingsState.downloadProgress.percentage)}%</span> + </div> + <div class="w-full bg-zinc-800 rounded-full h-2.5 overflow-hidden"> + <div + class="bg-gradient-to-r from-blue-500 to-cyan-400 h-2.5 rounded-full transition-all duration-200" + style="width: {settingsState.downloadProgress.percentage}%" + ></div> + </div> + </div> + + <!-- Speed & Stats --> + <div class="flex justify-between text-[10px] text-zinc-500 font-mono"> + <span> + {settingsState.formatBytes(settingsState.downloadProgress.speed_bytes_per_sec)}/s · + ETA: {settingsState.formatTime(settingsState.downloadProgress.eta_seconds)} + </span> + <span> + {settingsState.formatBytes(settingsState.downloadProgress.downloaded_bytes)} / + {settingsState.formatBytes(settingsState.downloadProgress.total_bytes)} + </span> + </div> + </div> + {/if} + + <!-- Pending Downloads Alert --> + {#if settingsState.pendingDownloads.length > 0 && !settingsState.isDownloadingJava} + <div class="border-t border-amber-500/30 p-4 bg-amber-500/10"> + <div class="flex items-center justify-between"> + <div class="flex items-center gap-3"> + <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/> + </svg> + <span class="text-sm text-amber-200"> + {settingsState.pendingDownloads.length} pending download(s) can be resumed + </span> + </div> + <button + onclick={() => settingsState.resumeDownloads()} + class="px-4 py-2 bg-amber-500/20 hover:bg-amber-500/30 text-amber-200 rounded-lg text-sm font-medium transition-colors" + > + Resume All + </button> + </div> + </div> + {/if} + + <!-- Footer Actions --> + <div class="flex items-center justify-between p-4 border-t border-white/10 bg-black/20"> + <button + onclick={() => settingsState.refreshCatalog()} + disabled={settingsState.isLoadingCatalog || settingsState.isDownloadingJava} + class="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 disabled:opacity-50 text-white/70 rounded-lg text-sm transition-colors" + > + <svg class="w-4 h-4 {settingsState.isLoadingCatalog ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> + </svg> + Refresh + </button> + + <div class="flex gap-3"> + {#if settingsState.isDownloadingJava} + <button + onclick={() => settingsState.cancelDownload()} + class="px-5 py-2.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-colors" + > + Cancel Download + </button> + {:else} + {@const isInstalled = settingsState.selectedRelease ? settingsState.getInstallStatus(settingsState.selectedRelease) === 'installed' : false} + <button + onclick={() => settingsState.closeJavaDownloadModal()} + class="px-5 py-2.5 bg-white/10 hover:bg-white/20 text-white rounded-lg text-sm font-medium transition-colors" + > + Close + </button> + <button + onclick={() => settingsState.downloadJava()} + disabled={!settingsState.selectedRelease?.is_available || settingsState.isLoadingCatalog || isInstalled} + class="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors" + > + {isInstalled ? 'Already Installed' : 'Download & Install'} + </button> + {/if} + </div> + </div> + </div> + </div> +{/if} diff --git a/ui/src/components/Sidebar.svelte b/ui/src/components/Sidebar.svelte index a4f4e35..1d7cc16 100644 --- a/ui/src/components/Sidebar.svelte +++ b/ui/src/components/Sidebar.svelte @@ -1,66 +1,89 @@ <script lang="ts"> import { uiState } from '../stores/ui.svelte'; + import { Home, Package, Settings } from 'lucide-svelte'; </script> <aside - class="w-20 lg:w-64 bg-zinc-950 flex flex-col items-center lg:items-start transition-all duration-300 border-r border-zinc-800 shrink-0" + class="w-20 lg:w-64 dark:bg-[#09090b] bg-white border-r dark:border-white/10 border-gray-200 flex flex-col items-center lg:items-start transition-all duration-300 shrink-0 py-6 z-20" > + <!-- Logo Area --> <div - class="h-20 w-full flex items-center justify-center lg:justify-start lg:px-6 border-b border-zinc-800/50" + class="h-16 w-full flex items-center justify-center lg:justify-start lg:px-6 mb-6" > - <!-- Icon Logo (Visible on small) --> - <div - class="lg:hidden text-2xl font-black bg-clip-text text-transparent bg-gradient-to-tr from-indigo-400 to-purple-400" - > - D + <!-- Icon Logo (Small) --> + <div class="lg:hidden text-black dark:text-white"> + <svg width="32" height="32" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M25 25 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> + <path d="M25 75 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> + <path d="M50 50 L75 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> + <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" /> + <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" /> + <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" /> + <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" /> + <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" /> + </svg> </div> - <!-- Full Logo (Visible on large) --> + <!-- Full Logo (Large) --> <div - class="hidden lg:block font-bold text-xl tracking-wider text-indigo-400" + class="hidden lg:flex items-center gap-3 font-bold text-xl tracking-tighter dark:text-white text-black" > - DROP<span class="text-white">OUT</span> + <!-- Neural Network Dropout Logo --> + <svg width="42" height="42" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" class="shrink-0"> + <!-- Lines --> + <path d="M25 25 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> + <path d="M25 75 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> + <path d="M50 50 L75 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" /> + + <!-- Input Layer (Left) --> + <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" /> + <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" /> + <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" /> + + <!-- Hidden Layer (Middle) - Dropout visualization --> + <!-- Dropped units (dashed) --> + <circle cx="50" cy="25" r="7" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2" fill="none" class="opacity-30" /> + <circle cx="50" cy="75" r="7" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2" fill="none" class="opacity-30" /> + <!-- Active unit --> + <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" /> + + <!-- Output Layer (Right) --> + <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" /> + </svg> + + <span>DROPOUT</span> </div> </div> - <nav class="flex-1 w-full flex flex-col gap-2 p-3"> - <button - class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView === - 'home' - ? 'bg-zinc-800/80 text-white' - : 'text-zinc-400'} transition-all relative" - onclick={() => uiState.setView("home")} - > - <span class="text-xl relative z-10">🏠</span> - <span - class="hidden lg:block font-medium relative z-10 transition-opacity" - >Home</span + <!-- Navigation --> + <nav class="flex-1 w-full flex flex-col gap-1 px-3"> + <!-- Nav Item Helper --> + {#snippet navItem(view, Icon, label)} + <button + class="group flex items-center lg:gap-3 justify-center lg:justify-start w-full px-0 lg:px-4 py-2.5 rounded-sm transition-all duration-200 relative + {uiState.currentView === view + ? 'bg-black/5 dark:bg-white/10 dark:text-white text-black font-medium' + : 'dark:text-zinc-400 text-zinc-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}" + onclick={() => uiState.setView(view)} > - </button> - <button - class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView === - 'versions' - ? 'bg-zinc-800/80 text-white' - : 'text-zinc-400'} transition-all" - onclick={() => uiState.setView("versions")} - > - <span class="text-xl">📦</span> - <span class="hidden lg:block font-medium">Versions</span> - </button> - <button - class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView === - 'settings' - ? 'bg-zinc-800/80 text-white' - : 'text-zinc-400'} transition-all" - onclick={() => uiState.setView("settings")} - > - <span class="text-xl">⚙️</span> - <span class="hidden lg:block font-medium">Settings</span> - </button> + <Icon size={20} strokeWidth={uiState.currentView === view ? 2.5 : 2} /> + <span class="hidden lg:block text-sm relative z-10">{label}</span> + + <!-- Active Indicator --> + {#if uiState.currentView === view} + <div class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-black dark:bg-white rounded-r-full hidden lg:block"></div> + {/if} + </button> + {/snippet} + + {@render navItem('home', Home, 'Overview')} + {@render navItem('versions', Package, 'Versions')} + {@render navItem('settings', Settings, 'Settings')} </nav> + <!-- Footer Info --> <div - class="p-4 w-full border-t border-zinc-800 flex justify-center lg:justify-start" + class="p-4 w-full flex justify-center lg:justify-start lg:px-6 opacity-40 hover:opacity-100 transition-opacity" > - <div class="text-xs text-zinc-600 font-mono">v{uiState.appVersion}</div> + <div class="text-[10px] font-mono text-zinc-500 uppercase tracking-wider">v{uiState.appVersion}</div> </div> </aside> diff --git a/ui/src/components/StatusToast.svelte b/ui/src/components/StatusToast.svelte index 0d68778..4c981c7 100644 --- a/ui/src/components/StatusToast.svelte +++ b/ui/src/components/StatusToast.svelte @@ -5,19 +5,19 @@ {#if uiState.status !== "Ready"} {#key uiState.status} <div - class="absolute top-12 right-12 bg-zinc-800/90 backdrop-blur border border-zinc-600 p-4 rounded-lg shadow-2xl max-w-sm animate-in fade-in slide-in-from-top-4 duration-300 z-50 group" + class="absolute top-12 right-12 bg-white/90 dark:bg-zinc-800/90 backdrop-blur border border-zinc-200 dark:border-zinc-600 p-4 rounded-lg shadow-2xl max-w-sm animate-in fade-in slide-in-from-top-4 duration-300 z-50 group" > <div class="flex justify-between items-start mb-1"> - <div class="text-xs text-zinc-400 uppercase font-bold">Status</div> + <div class="text-xs text-zinc-500 dark:text-zinc-400 uppercase font-bold">Status</div> <button onclick={() => uiState.setStatus("Ready")} - class="text-zinc-500 hover:text-white transition -mt-1 -mr-1 p-1" + class="text-zinc-400 hover:text-black dark:text-zinc-500 dark:hover:text-white transition -mt-1 -mr-1 p-1" > ✕ </button> </div> - <div class="font-mono text-sm whitespace-pre-wrap mb-2">{uiState.status}</div> - <div class="w-full bg-zinc-700/50 h-1 rounded-full overflow-hidden"> + <div class="font-mono text-sm whitespace-pre-wrap mb-2 text-gray-900 dark:text-gray-100">{uiState.status}</div> + <div class="w-full bg-gray-200 dark:bg-zinc-700/50 h-1 rounded-full overflow-hidden"> <div class="h-full bg-indigo-500 origin-left w-full progress-bar" ></div> diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte index 98261b8..99cc296 100644 --- a/ui/src/components/VersionsView.svelte +++ b/ui/src/components/VersionsView.svelte @@ -1,55 +1,237 @@ <script lang="ts"> + import { invoke } from "@tauri-apps/api/core"; import { gameState } from "../stores/game.svelte"; + import ModLoaderSelector from "./ModLoaderSelector.svelte"; let searchQuery = $state(""); let normalizedQuery = $derived( searchQuery.trim().toLowerCase().replace(/。/g, ".") ); - let filteredVersions = $derived( - gameState.versions.filter((v) => - v.id.toLowerCase().includes(normalizedQuery) - ) - ); + // Filter by version type + let typeFilter = $state<"all" | "release" | "snapshot" | "modded">("all"); + + // Installed modded versions + let installedFabricVersions = $state<string[]>([]); + let isLoadingModded = $state(false); + + // Load installed modded versions + async function loadInstalledModdedVersions() { + isLoadingModded = true; + try { + installedFabricVersions = await invoke<string[]>( + "list_installed_fabric_versions" + ); + } catch (e) { + console.error("Failed to load installed fabric versions:", e); + } finally { + isLoadingModded = false; + } + } + + // Load on mount + $effect(() => { + loadInstalledModdedVersions(); + }); + + // Combined versions list (vanilla + modded) + let allVersions = $derived(() => { + const moddedVersions = installedFabricVersions.map((id) => ({ + id, + type: "fabric", + url: "", + time: "", + releaseTime: new Date().toISOString(), + })); + return [...moddedVersions, ...gameState.versions]; + }); + + let filteredVersions = $derived(() => { + let versions = allVersions(); + + // Apply type filter + if (typeFilter === "release") { + versions = versions.filter((v) => v.type === "release"); + } else if (typeFilter === "snapshot") { + versions = versions.filter((v) => v.type === "snapshot"); + } else if (typeFilter === "modded") { + versions = versions.filter( + (v) => v.type === "fabric" || v.type === "forge" + ); + } + + // Apply search filter + if (normalizedQuery.length > 0) { + versions = versions.filter((v) => + v.id.toLowerCase().includes(normalizedQuery) + ); + } + + return versions; + }); + + function getVersionBadge(type: string) { + switch (type) { + case "release": + return { text: "Release", class: "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-300 dark:border-emerald-500/30" }; + case "snapshot": + return { text: "Snapshot", class: "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/20 dark:text-amber-300 dark:border-amber-500/30" }; + case "fabric": + return { text: "Fabric", class: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:border-indigo-500/30" }; + case "forge": + return { text: "Forge", class: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-300 dark:border-orange-500/30" }; + default: + return { text: type, class: "bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-zinc-500/20 dark:text-zinc-300 dark:border-zinc-500/30" }; + } + } + + function handleModLoaderInstall(versionId: string) { + // Refresh the installed versions list + loadInstalledModdedVersions(); + // Select the newly installed version + gameState.selectedVersion = versionId; + } + + // Get the base Minecraft version from selected version (for mod loader selector) + let selectedBaseVersion = $derived(() => { + const selected = gameState.selectedVersion; + if (!selected) return ""; + + // If it's a modded version, extract the base version + if (selected.startsWith("fabric-loader-")) { + // Format: fabric-loader-X.X.X-1.20.4 + const parts = selected.split("-"); + return parts[parts.length - 1]; + } + if (selected.includes("-forge-")) { + // Format: 1.20.4-forge-49.0.38 + return selected.split("-forge-")[0]; + } + + // Check if it's a valid vanilla version + const version = gameState.versions.find((v) => v.id === selected); + return version ? selected : ""; + }); </script> -<div class="p-8 h-full overflow-y-auto bg-zinc-900"> - <h2 class="text-3xl font-bold mb-6">Versions</h2> - - <input - type="text" - placeholder="Search versions..." - class="w-full p-3 mb-4 bg-zinc-800 border border-zinc-700 rounded text-white focus:outline-none focus:border-green-500 transition-colors" - bind:value={searchQuery} - /> - - <div class="grid gap-2"> - {#if gameState.versions.length === 0} - <div class="text-zinc-500">Loading versions...</div> - {:else if filteredVersions.length === 0 && normalizedQuery.length > 0} - <div class="text-zinc-500">No versions found matching "{searchQuery}"</div> - {:else} - {#each filteredVersions as version} - <button - class="flex items-center justify-between p-4 bg-zinc-800 rounded hover:bg-zinc-700 transition text-left border border-zinc-700 {gameState.selectedVersion === - version.id - ? 'border-green-500 bg-zinc-800/80 ring-1 ring-green-500' - : ''}" - onclick={() => (gameState.selectedVersion = version.id)} - > - <div> - <div class="font-bold font-mono text-lg">{version.id}</div> - <div class="text-xs text-zinc-400 capitalize"> - {version.type} • {new Date( - version.releaseTime - ).toLocaleDateString()} - </div> +<div class="h-full flex flex-col p-6 overflow-hidden"> + <div class="flex items-center justify-between mb-6"> + <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/60">Version Manager</h2> + <div class="text-sm dark:text-white/40 text-black/50">Select a version to play or modify</div> + </div> + + <div class="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 overflow-hidden"> + <!-- Left: Version List --> + <div class="lg:col-span-2 flex flex-col gap-4 overflow-hidden"> + <!-- Search and Filters (Glass Bar) --> + <div class="flex gap-3"> + <div class="relative flex-1"> + <span class="absolute left-3 top-1/2 -translate-y-1/2 dark:text-white/30 text-black/30">🔍</span> + <input + type="text" + placeholder="Search versions..." + class="w-full pl-9 pr-4 py-3 bg-white/60 dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-xl dark:text-white text-gray-900 placeholder-black/30 dark:placeholder-white/30 focus:outline-none focus:border-indigo-500/50 dark:focus:bg-black/40 focus:bg-white/80 transition-all backdrop-blur-sm" + bind:value={searchQuery} + /> + </div> + </div> + + <!-- Type Filter Tabs (Glass Caps) --> + <div class="flex p-1 bg-white/60 dark:bg-black/20 rounded-xl border border-black/5 dark:border-white/5"> + {#each ['all', 'release', 'snapshot', 'modded'] as filter} + <button + class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 capitalize + {typeFilter === filter + ? 'bg-white shadow-sm border border-black/5 dark:bg-white/10 dark:text-white dark:shadow-lg dark:border-white/10 text-black' + : 'text-black/40 dark:text-white/40 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}" + onclick={() => (typeFilter = filter as any)} + > + {filter} + </button> + {/each} + </div> + + <!-- Version List SCROLL --> + <div class="flex-1 overflow-y-auto pr-2 space-y-2 custom-scrollbar"> + {#if gameState.versions.length === 0} + <div class="flex items-center justify-center h-40 dark:text-white/30 text-black/30 italic animate-pulse"> + Fetching manifest... </div> - {#if gameState.selectedVersion === version.id} - <div class="text-green-500 font-bold text-sm">SELECTED</div> + {:else if filteredVersions().length === 0} + <div class="flex flex-col items-center justify-center h-40 dark:text-white/30 text-black/30 gap-2"> + <span class="text-2xl">👻</span> + <span>No matching versions found</span> + </div> + {:else} + {#each filteredVersions() as version} + {@const badge = getVersionBadge(version.type)} + {@const isSelected = gameState.selectedVersion === version.id} + <button + class="w-full group flex items-center justify-between p-4 rounded-xl text-left border transition-all duration-200 relative overflow-hidden + {isSelected + ? 'bg-indigo-50 border-indigo-200 dark:bg-indigo-600/20 dark:border-indigo-500/50 shadow-[0_0_20px_rgba(99,102,241,0.2)]' + : 'bg-white/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/10 dark:hover:border-white/10 hover:translate-x-1'}" + onclick={() => (gameState.selectedVersion = version.id)} + > + <!-- Selection Glow --> + {#if isSelected} + <div class="absolute inset-0 bg-gradient-to-r from-indigo-500/10 to-transparent pointer-events-none"></div> + {/if} + + <div class="relative z-10 flex items-center gap-4"> + <span + class="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border {badge.class}" + > + {badge.text} + </span> + <div> + <div class="font-bold font-mono text-lg tracking-tight {isSelected ? 'text-black dark:text-white' : 'text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white'}"> + {version.id} + </div> + {#if version.releaseTime && version.type !== "fabric" && version.type !== "forge"} + <div class="text-xs dark:text-white/30 text-black/30"> + {new Date(version.releaseTime).toLocaleDateString()} + </div> + {/if} + </div> + </div> + + {#if isSelected} + <div class="relative z-10 text-indigo-500 dark:text-indigo-400"> + <span class="text-lg">Selected</span> + </div> + {/if} + </button> + {/each} + {/if} + </div> + </div> + + <!-- Right: Mod Loader Panel --> + <div class="flex flex-col gap-4"> + <!-- Selected Version Info Card --> + <div class="bg-gradient-to-br from-white/40 to-white/20 dark:from-white/10 dark:to-white/5 p-6 rounded-2xl border border-black/5 dark:border-white/10 backdrop-blur-md relative overflow-hidden group"> + <div class="absolute top-0 right-0 p-8 bg-indigo-500/20 blur-[60px] rounded-full group-hover:bg-indigo-500/30 transition-colors"></div> + + <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-2 relative z-10">Current Selection</h3> + {#if gameState.selectedVersion} + <p class="font-mono text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/70 relative z-10 truncate"> + {gameState.selectedVersion} + </p> + {:else} + <p class="dark:text-white/20 text-black/20 italic relative z-10">None selected</p> {/if} - </button> - {/each} - {/if} + </div> + + <!-- Mod Loader Selector Card --> + <div class="bg-white/60 dark:bg-black/20 p-4 rounded-2xl border border-black/5 dark:border-white/5 backdrop-blur-sm flex-1 flex flex-col"> + <ModLoaderSelector + selectedGameVersion={selectedBaseVersion()} + onInstall={handleModLoaderInstall} + /> + </div> + + </div> </div> </div> + diff --git a/ui/src/lib/DownloadMonitor.svelte b/ui/src/lib/DownloadMonitor.svelte index 52c935c..860952c 100644 --- a/ui/src/lib/DownloadMonitor.svelte +++ b/ui/src/lib/DownloadMonitor.svelte @@ -156,7 +156,7 @@ {#if visible} <div - class="fixed bottom-28 right-8 z-50 w-80 bg-zinc-900/90 backdrop-blur-md border border-zinc-700 rounded-lg shadow-2xl p-4 animate-in slide-in-from-right-10 fade-in duration-300" + 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> diff --git a/ui/src/lib/GameConsole.svelte b/ui/src/lib/GameConsole.svelte index 281dc85..bc5edbc 100644 --- a/ui/src/lib/GameConsole.svelte +++ b/ui/src/lib/GameConsole.svelte @@ -1,107 +1,304 @@ <script lang="ts"> - import { listen } from "@tauri-apps/api/event"; - import { onMount, onDestroy } from "svelte"; + 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'; - export let visible = false; - - let logs: { type: 'stdout' | 'stderr' | 'launcher', line: string, timestamp: string }[] = []; let consoleElement: HTMLDivElement; - let unlistenStdout: () => void; - let unlistenStderr: () => void; - let unlistenLauncher: () => void; - let unlistenGameExited: () => void; - - function getTimestamp(): string { - const now = new Date(); - return now.toTimeString().split(' ')[0]; // HH:MM:SS - } + 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"); - onMount(async () => { - // Listen for launcher logs (preparation, downloads, launch status) - unlistenLauncher = await listen<string>("launcher-log", (event) => { - addLog('launcher', event.payload); - }); - - // Listen for game stdout - unlistenStdout = await listen<string>("game-stdout", (event) => { - addLog('stdout', event.payload); - }); - - // Listen for game stderr - unlistenStderr = await listen<string>("game-stderr", (event) => { - addLog('stderr', event.payload); - }); - - // Listen for game exit event - unlistenGameExited = await listen<number>("game-exited", (event) => { - addLog('launcher', `Game process exited with code: ${event.payload}`); - }); - }); + // Get sorted sources for dropdown + let sourceOptions = $derived([ + { value: "all", label: "All Sources" }, + ...[...logsState.sources].sort().map(s => ({ value: s, label: s })) + ]); - onDestroy(() => { - if (unlistenLauncher) unlistenLauncher(); - if (unlistenStdout) unlistenStdout(); - if (unlistenStderr) unlistenStderr(); - if (unlistenGameExited) unlistenGameExited(); - }); + // Derived filtered logs + let filteredLogs = $derived(logsState.logs.filter((log) => { + // Source Filter + if (selectedSource !== "all" && log.source !== selectedSource) return false; - function addLog(type: 'stdout' | 'stderr' | 'launcher', line: string) { - logs = [...logs, { type, line, timestamp: getTimestamp() }]; - if (logs.length > 2000) { - logs = logs.slice(logs.length - 2000); + // 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) + ); } - // Auto-scroll - setTimeout(() => { - if (consoleElement) { + 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; - } - }, 0); + }); + } + }); + + 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}`); + } } - function clearLogs() { - logs = []; + // 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 exportLogs() { - const logText = logs.map(l => `[${l.timestamp}] [${l.type.toUpperCase()}] ${l.line}`).join('\n'); - const blob = new Blob([logText], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `dropout-logs-${new Date().toISOString().split('T')[0]}.txt`; - a.click(); - URL.revokeObjectURL(url); + 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> -{#if visible} -<div class="fixed bottom-0 left-0 right-0 h-64 bg-zinc-950/95 border-t border-zinc-700 backdrop-blur flex flex-col z-50 transition-transform duration-300 transform translate-y-0"> - <div class="flex items-center justify-between px-4 py-2 border-b border-zinc-800 bg-zinc-900/50"> - <div class="flex items-center gap-4"> - <span class="text-xs font-bold text-zinc-400 uppercase tracking-widest">Logs</span> - <div class="flex gap-1 text-[10px]"> - <span class="px-1.5 py-0.5 rounded bg-indigo-900/50 text-indigo-300">LAUNCHER</span> - <span class="px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-300">GAME</span> - <span class="px-1.5 py-0.5 rounded bg-red-900/50 text-red-300">ERROR</span> - </div> +<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> - <div class="flex gap-2"> - <button on:click={exportLogs} class="text-xs text-zinc-500 hover:text-white px-2 py-1 rounded transition">Export</button> - <button on:click={clearLogs} class="text-xs text-zinc-500 hover:text-white px-2 py-1 rounded transition">Clear</button> - <button on:click={() => visible = false} class="text-xs text-zinc-500 hover:text-white px-2 py-1 rounded transition">Close</button> + + <!-- 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> - <div bind:this={consoleElement} class="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5"> - {#each logs as log} - <div class="flex whitespace-pre-wrap break-all {log.type === 'stderr' ? 'text-red-400' : log.type === 'launcher' ? 'text-indigo-300' : 'text-zinc-300'}"> - <span class="text-zinc-600 mr-2 shrink-0">{log.timestamp}</span> - <span class="shrink-0 mr-2 {log.type === 'stderr' ? 'text-red-500' : log.type === 'launcher' ? 'text-indigo-500' : 'text-zinc-500'}">[{log.type === 'launcher' ? 'LAUNCHER' : log.type === 'stderr' ? 'ERROR' : 'GAME'}]</span> - <span class="break-all">{log.line}</span> - </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 logs.length === 0} - <div class="text-zinc-600 italic">Waiting for output... Click "Show Logs" and start a game to see logs here.</div> + + {#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> -{/if} + +<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/ui/src/lib/effects/ConstellationEffect.ts b/ui/src/lib/effects/ConstellationEffect.ts new file mode 100644 index 0000000..2cc702e --- /dev/null +++ b/ui/src/lib/effects/ConstellationEffect.ts @@ -0,0 +1,163 @@ + +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/ui/src/lib/effects/SaturnEffect.ts b/ui/src/lib/effects/SaturnEffect.ts new file mode 100644 index 0000000..a370936 --- /dev/null +++ b/ui/src/lib/effects/SaturnEffect.ts @@ -0,0 +1,194 @@ +// 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; + + 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(); + } + + 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'; + + // Slower rotation (from 0.0015 to 0.0005) + this.angle += 0.0005; + + 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/ui/src/lib/modLoaderApi.ts b/ui/src/lib/modLoaderApi.ts new file mode 100644 index 0000000..9d0d09d --- /dev/null +++ b/ui/src/lib/modLoaderApi.ts @@ -0,0 +1,108 @@ +/** + * 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/ui/src/stores/game.svelte.ts b/ui/src/stores/game.svelte.ts index 0af3daf..28b2db5 100644 --- a/ui/src/stores/game.svelte.ts +++ b/ui/src/stores/game.svelte.ts @@ -7,6 +7,10 @@ export class GameState { versions = $state<Version[]>([]); selectedVersion = $state(""); + get latestRelease() { + return this.versions.find((v) => v.type === "release"); + } + async loadVersions() { try { this.versions = await invoke<Version[]>("get_versions"); diff --git a/ui/src/stores/logs.svelte.ts b/ui/src/stores/logs.svelte.ts new file mode 100644 index 0000000..5491f70 --- /dev/null +++ b/ui/src/stores/logs.svelte.ts @@ -0,0 +1,139 @@ +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"); + this.setupListeners(); + } + + 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 async setupListeners() { + // 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/ui/src/stores/releases.svelte.ts b/ui/src/stores/releases.svelte.ts new file mode 100644 index 0000000..c858abb --- /dev/null +++ b/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/ui/src/stores/settings.svelte.ts b/ui/src/stores/settings.svelte.ts index 397b9a6..b85e5fb 100644 --- a/ui/src/stores/settings.svelte.ts +++ b/ui/src/stores/settings.svelte.ts @@ -1,5 +1,15 @@ import { invoke } from "@tauri-apps/api/core"; -import type { LauncherConfig, JavaInstallation } from "../types"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { + JavaCatalog, + JavaDownloadProgress, + JavaDownloadSource, + JavaInstallation, + JavaReleaseInfo, + LauncherConfig, + PendingJavaDownload, +} from "../types"; import { uiState } from "./ui.svelte"; export class SettingsState { @@ -10,14 +20,133 @@ export class SettingsState { 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, }); + + // 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[]>([]); + + // Event listener cleanup + private progressUnlisten: UnlistenFn | null = null; + + // 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); } @@ -25,6 +154,11 @@ export class SettingsState { 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) { @@ -53,6 +187,205 @@ export class SettingsState { 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; + } } export const settingsState = new SettingsState(); diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 1f83585..09a7d5e 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -30,6 +30,13 @@ export interface LauncherConfig { 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; } export interface JavaInstallation { @@ -37,3 +44,116 @@ export interface JavaInstallation { 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"; + diff --git a/ui/vite.config.ts b/ui/vite.config.ts index d030e58..d5fcbc5 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ // Fix for Tauri + Vite HMR server: { + host: true, strictPort: true, hmr: { protocol: 'ws', |