21 Commits

Author SHA1 Message Date
932d867505 paint buttons / minor change 2025-07-21 15:17:21 +05:00
212b58c072 add: registration page 2025-07-21 10:55:55 +05:00
3d78d3e279 feat: improved TopBar and empty registration page 2025-07-21 08:27:35 +05:00
e018aec8db debug minecraft-launcher.ts 2025-07-20 20:15:09 +05:00
fa115e0a6c fix: package, auth url 2025-07-20 03:15:13 +05:00
51b155e70a fix: AuthForm 50/50 2025-07-20 02:49:58 +05:00
6ee1b67315 add: marketplace 2025-07-19 04:40:46 +05:00
c39a8bc43c add: if player online when display marketplace in TopBar 2025-07-19 02:07:34 +05:00
56da7c7543 add: shop capes and refactor cape card 2025-07-19 01:36:33 +05:00
26f601635b add: capes switch in user profile 2025-07-18 20:14:44 +05:00
ec54219192 add: skin viewer, refatoring to api 2025-07-18 18:29:34 +05:00
f3aa32a60a add: display coins in TopBar 2025-07-18 01:30:39 +05:00
7938555c91 feat: your authorization 2025-07-18 00:51:08 +05:00
591e354dcb fix: names 2025-07-14 00:48:30 +05:00
ce141a014c remove loading in ServerStatus 2025-07-14 00:30:20 +05:00
3d4c9c89ef feat: rounded icons TopBar 2025-07-14 00:27:42 +05:00
90d4bb80e6 feat: custom minimizeIcon 2025-07-14 00:18:29 +05:00
fdf5c7a90d fix: styles TopBar 2025-07-14 00:12:14 +05:00
387d1548ba feat: update styles
add: TopBar title
2025-07-13 23:52:28 +05:00
942066ea76 feat: improve Minecraft version handling 2025-07-13 23:37:46 +05:00
815ce286f7 add: VersionExplorer, don't work first run Minecraft 2025-07-08 03:29:36 +05:00
29 changed files with 4134 additions and 253 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
public/
# Logs # Logs
logs logs
*.log *.log

BIN
assets/icons/popa-popa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -1,11 +1,23 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" width="8" height="4" fill="#FF2D0F"/> <rect x="0" y="12" width="28" height="4" fill="#BD2211"/>
<rect x="8" y="4" width="4" height="16" fill="#FF2D0F"/> <rect x="4" y="16" width="20" height="4" fill="#BD2211"/>
<rect x="4" y="8" width="4" height="8" fill="#FF2D0F"/> <rect x="8" y="20" width="12" height="4" fill="#BD2211"/>
<rect x="12" y="24" width="4" height="4" fill="#BD2211"/>
<rect x="4" width="8" height="4.5" fill="#FF2D0F"/>
<rect x="16" width="8" height="4.5" fill="#FF2D0F"/>
<rect x="0" y="4" width="28" height="8" fill="#FF2D0F"/>
<rect x="4" y="8" width="20" height="8" fill="#FF2D0F"/>
<rect x="8" y="12" width="12" height="8" fill="#FF2D0F"/>
<rect x="12" y="16" width="4" height="8" fill="#FF2D0F"/>
<rect x="4" y="4" width="4" height="4" fill="#FFCAC8"/>
<!-- <rect x="7" y="4" width="6" height="16" fill="#FF2D0F"/>
<rect x="6" y="3" width="6" height="16" fill="#FF2D0F"/>
<rect x="3" y="8" width="4" height="8" fill="#FF2D0F"/>
<rect y="4" width="4" height="8" fill="#FF2D0F"/> <rect y="4" width="4" height="8" fill="#FF2D0F"/>
<rect x="24" y="4" width="4" height="8" fill="#FF2D0F"/> <rect x="24" y="4" width="4" height="8" fill="#FF2D0F"/>
<rect x="16" width="8" height="16" fill="#FF2D0F"/> <rect x="16" width="8" height="16" fill="#FF2D0F"/>
<rect x="16" y="16" width="4" height="4" fill="#FF2D0F"/> <rect x="20" y="4" width="8" height="12" fill="#FF2D0F"/>
<rect x="15" y="4" width="5" height="16" fill="#FF2D0F"/>
<rect x="24" y="12" width="4" height="4" fill="#BD2211"/> <rect x="24" y="12" width="4" height="4" fill="#BD2211"/>
<rect x="20" y="16" width="4" height="4" fill="#BD2211"/> <rect x="20" y="16" width="4" height="4" fill="#BD2211"/>
<rect x="16" y="20" width="4" height="4" fill="#BD2211"/> <rect x="16" y="20" width="4" height="4" fill="#BD2211"/>
@ -14,5 +26,5 @@
<rect x="4" y="16" width="4" height="4" fill="#BD2211"/> <rect x="4" y="16" width="4" height="4" fill="#BD2211"/>
<rect x="4" y="4" width="4" height="4" fill="#FFCAC8"/> <rect x="4" y="4" width="4" height="4" fill="#FFCAC8"/>
<rect y="12" width="4" height="4" fill="#BD2211"/> <rect y="12" width="4" height="4" fill="#BD2211"/>
<rect x="12" y="4" width="4" height="20" fill="#FF2D0F"/> <rect x="12" y="4" width="4" height="20" fill="#FF2D0F"/> -->
</svg> </svg>

Before

Width:  |  Height:  |  Size: 994 B

After

Width:  |  Height:  |  Size: 1.7 KiB

169
package-lock.json generated
View File

@ -15,6 +15,7 @@
"@mui/material": "^7.2.0", "@mui/material": "^7.2.0",
"@xmcl/core": "^2.14.1", "@xmcl/core": "^2.14.1",
"@xmcl/installer": "^6.1.0", "@xmcl/installer": "^6.1.0",
"@xmcl/resourcepack": "^1.2.4",
"@xmcl/user": "^4.2.0", "@xmcl/user": "^4.2.0",
"electron-debug": "^4.1.0", "electron-debug": "^4.1.0",
"electron-log": "^5.3.2", "electron-log": "^5.3.2",
@ -22,10 +23,13 @@
"find-java-home": "^2.0.0", "find-java-home": "^2.0.0",
"https-browserify": "^1.0.0", "https-browserify": "^1.0.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"qr-code-styling": "^1.9.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.3.0", "react-router-dom": "^7.3.0",
"skinview3d": "^3.4.1",
"stream-browserify": "^3.0.0", "stream-browserify": "^3.0.0",
"three": "^0.178.0",
"undici": "^7.11.0", "undici": "^7.11.0",
"util": "^0.12.5", "util": "^0.12.5",
"uuid": "^11.1.0" "uuid": "^11.1.0"
@ -43,6 +47,7 @@
"@types/react": "^19.0.11", "@types/react": "^19.0.11",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/react-test-renderer": "^19.0.0", "@types/react-test-renderer": "^19.0.0",
"@types/three": "^0.178.1",
"@types/webpack-bundle-analyzer": "^4.7.0", "@types/webpack-bundle-analyzer": "^4.7.0",
"@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/parser": "^8.26.1",
@ -2228,6 +2233,13 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@discoveryjs/json-ext": { "node_modules/@discoveryjs/json-ext": {
"version": "0.5.7", "version": "0.5.7",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
@ -4897,6 +4909,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/aria-query": { "node_modules/@types/aria-query": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@ -5418,6 +5437,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.178.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.178.1.tgz",
"integrity": "sha512-WSabew1mgWgRx2RfLfKY+9h4wyg6U94JfLbZEGU245j/WY2kXqU0MUfghS+3AYMV5ET1VlILAgpy77cB6a3Itw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/tough-cookie": { "node_modules/@types/tough-cookie": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
@ -5445,6 +5486,12 @@
"webpack": "^5" "webpack": "^5"
} }
}, },
"node_modules/@types/webxr": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.22.tgz",
"integrity": "sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A==",
"license": "MIT"
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.5.12", "version": "8.5.12",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz",
@ -5856,6 +5903,13 @@
"@xtuc/long": "4.2.2" "@xtuc/long": "4.2.2"
} }
}, },
"node_modules/@webgpu/types": {
"version": "0.1.64",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.64.tgz",
"integrity": "sha512-84kRIAGV46LJTlJZWxShiOrNL30A+9KokD7RB3dRCIqODFjodS5tCD5yyiZ8kIReGVZSDfA3XkkwyyOIF6K62A==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@webpack-cli/configtest": { "node_modules/@webpack-cli/configtest": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz", "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz",
@ -5988,6 +6042,32 @@
"node": ">=20.18.1" "node": ">=20.18.1"
} }
}, },
"node_modules/@xmcl/resourcepack": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@xmcl/resourcepack/-/resourcepack-1.2.4.tgz",
"integrity": "sha512-OlDOBAX33EKHC0PYC68a6RW/mBtfmskl0OKbsP8gSPM7RH7zqR2SNR+u5AAavEhpmCvjThztv6KwHifGk6/t/Q==",
"license": "MIT",
"dependencies": {
"@xmcl/system": "2.2.8"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@xmcl/system": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@xmcl/system/-/system-2.2.8.tgz",
"integrity": "sha512-G5argPsvKqvYDfUE1z+pVCIuNbhuaC+YXWlQHHXgMSpKSDnJRQoYvlQAcTorlFCqFqtL5a51hV+Rmsug0geJuA==",
"license": "MIT",
"dependencies": {
"@xmcl/unzip": "2.1.2",
"jszip": "^3.10.1",
"yauzl": "^2.10.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@xmcl/task": { "node_modules/@xmcl/task": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/@xmcl/task/-/task-4.1.1.tgz", "resolved": "https://registry.npmjs.org/@xmcl/task/-/task-4.1.1.tgz",
@ -8435,7 +8515,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cosmiconfig": { "node_modules/cosmiconfig": {
@ -11943,6 +12022,13 @@
"pend": "~1.2.0" "pend": "~1.2.0"
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -13302,7 +13388,6 @@
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/immutable": { "node_modules/immutable": {
@ -15358,7 +15443,6 @@
"version": "3.10.1", "version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dev": true,
"license": "(MIT OR GPL-3.0-or-later)", "license": "(MIT OR GPL-3.0-or-later)",
"dependencies": { "dependencies": {
"lie": "~3.3.0", "lie": "~3.3.0",
@ -15371,14 +15455,12 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jszip/node_modules/readable-stream": { "node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
@ -15394,14 +15476,12 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jszip/node_modules/string_decoder": { "node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
@ -15572,7 +15652,6 @@
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"immediate": "~3.0.5" "immediate": "~3.0.5"
@ -15959,6 +16038,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
"license": "MIT"
},
"node_modules/methods": { "node_modules/methods": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@ -16939,7 +17024,6 @@
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"dev": true,
"license": "(MIT AND Zlib)" "license": "(MIT AND Zlib)"
}, },
"node_modules/param-case": { "node_modules/param-case": {
@ -17921,7 +18005,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/progress": { "node_modules/progress": {
@ -18054,6 +18137,24 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/qr-code-styling": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/qr-code-styling/-/qr-code-styling-1.9.2.tgz",
"integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==",
"license": "MIT",
"dependencies": {
"qrcode-generator": "^1.4.4"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/qrcode-generator": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.5.2.tgz",
"integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==",
"license": "MIT"
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.13.0", "version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@ -19482,7 +19583,6 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
@ -19656,6 +19756,47 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/skinview-utils": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/skinview-utils/-/skinview-utils-0.7.1.tgz",
"integrity": "sha512-4eLrMqR526ehlZbsd8SuZ/CHpS9GiH0xUMoV+PYlJVi95ZFz5HJu7Spt5XYa72DRS7wgt5qquvHZf0XZJgmu9Q==",
"license": "MIT"
},
"node_modules/skinview3d": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/skinview3d/-/skinview3d-3.4.1.tgz",
"integrity": "sha512-WVN1selfDSAoQB7msLs3ueJjW/pge3nsmbqxJeXPnN/qIJ1GJKpMZO8mavSvMojaMrmpSgOJWfYUkK9B34ts2g==",
"license": "MIT",
"dependencies": {
"@types/three": "^0.156.0",
"skinview-utils": "^0.7.1",
"three": "^0.156.0"
}
},
"node_modules/skinview3d/node_modules/@types/three": {
"version": "0.156.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.156.0.tgz",
"integrity": "sha512-733bXDSRdlrxqOmQuOmfC1UBRuJ2pREPk8sWnx9MtIJEVDQMx8U0NQO5MVVaOrjzDPyLI+cFPim2X/ss9v0+LQ==",
"license": "MIT",
"dependencies": {
"@types/stats.js": "*",
"@types/webxr": "*",
"fflate": "~0.6.10",
"meshoptimizer": "~0.18.1"
}
},
"node_modules/skinview3d/node_modules/fflate": {
"version": "0.6.10",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz",
"integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
"license": "MIT"
},
"node_modules/skinview3d/node_modules/three": {
"version": "0.156.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.156.1.tgz",
"integrity": "sha512-kP7H0FK9d/k6t/XvQ9FO6i+QrePoDcNhwl0I02+wmUJRNSLCUIDMcfObnzQvxb37/0Uc9TDT0T1HgsRRrO6SYQ==",
"license": "MIT"
},
"node_modules/slash": { "node_modules/slash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -20603,6 +20744,12 @@
"tslib": "^2" "tslib": "^2"
} }
}, },
"node_modules/three": {
"version": "0.178.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.178.0.tgz",
"integrity": "sha512-ybFIB0+x8mz0wnZgSGy2MO/WCO6xZhQSZnmfytSPyNpM0sBafGRVhdaj+erYh5U+RhQOAg/eXqw5uVDiM2BjhQ==",
"license": "MIT"
},
"node_modules/thunky": { "node_modules/thunky": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",

View File

@ -109,6 +109,7 @@
"@mui/material": "^7.2.0", "@mui/material": "^7.2.0",
"@xmcl/core": "^2.14.1", "@xmcl/core": "^2.14.1",
"@xmcl/installer": "^6.1.0", "@xmcl/installer": "^6.1.0",
"@xmcl/resourcepack": "^1.2.4",
"@xmcl/user": "^4.2.0", "@xmcl/user": "^4.2.0",
"electron-debug": "^4.1.0", "electron-debug": "^4.1.0",
"electron-log": "^5.3.2", "electron-log": "^5.3.2",
@ -116,10 +117,13 @@
"find-java-home": "^2.0.0", "find-java-home": "^2.0.0",
"https-browserify": "^1.0.0", "https-browserify": "^1.0.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"qr-code-styling": "^1.9.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.3.0", "react-router-dom": "^7.3.0",
"skinview3d": "^3.4.1",
"stream-browserify": "^3.0.0", "stream-browserify": "^3.0.0",
"three": "^0.178.0",
"undici": "^7.11.0", "undici": "^7.11.0",
"util": "^0.12.5", "util": "^0.12.5",
"uuid": "^11.1.0" "uuid": "^11.1.0"
@ -137,6 +141,7 @@
"@types/react": "^19.0.11", "@types/react": "^19.0.11",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/react-test-renderer": "^19.0.0", "@types/react-test-renderer": "^19.0.0",
"@types/three": "^0.178.1",
"@types/webpack-bundle-analyzer": "^4.7.0", "@types/webpack-bundle-analyzer": "^4.7.0",
"@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/parser": "^8.26.1",

View File

@ -1,8 +1,9 @@
import { YggdrasilClient, YggrasilAuthentication } from '@xmcl/user'; import { YggdrasilClient, YggrasilAuthentication } from '@xmcl/user';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { API_BASE_URL } from '../renderer/api';
// Ely.by сервер // Ely.by сервер
const ELY_BY_AUTH_SERVER = 'https://authserver.ely.by'; const ELY_BY_AUTH_SERVER = API_BASE_URL;
export class AuthService { export class AuthService {
private client: YggdrasilClient; private client: YggdrasilClient;
@ -49,6 +50,7 @@ export class AuthService {
async validate(accessToken: string, clientToken: string): Promise<boolean> { async validate(accessToken: string, clientToken: string): Promise<boolean> {
try { try {
console.log(accessToken, clientToken);
const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/validate`, { const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/validate`, {
method: 'POST', method: 'POST',
headers: { headers: {

View File

@ -119,7 +119,7 @@ const createWindow = async () => {
width: 1024, width: 1024,
height: 850, height: 850,
autoHideMenuBar: true, autoHideMenuBar: true,
resizable: false, resizable: true,
frame: false, frame: false,
icon: getAssetPath('icon.png'), icon: getAssetPath('icon.png'),
webPreferences: { webPreferences: {

View File

@ -16,6 +16,7 @@ import {
} from '@xmcl/installer'; } from '@xmcl/installer';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { AuthService } from './auth-service'; import { AuthService } from './auth-service';
import { API_BASE_URL } from '../renderer/api';
// Константы // Константы
const AUTHLIB_INJECTOR_FILENAME = 'authlib-injector-1.2.5.jar'; const AUTHLIB_INJECTOR_FILENAME = 'authlib-injector-1.2.5.jar';
@ -280,7 +281,7 @@ export async function findJava(): Promise<string> {
}); });
// Предпочитаем Java 21 или 17 для совместимости с authlib-injector // Предпочитаем Java 21 или 17 для совместимости с authlib-injector
const preferredVersions = [21, 17, 11]; const preferredVersions = [24, 21, 17, 11];
for (const preferredVersion of preferredVersions) { for (const preferredVersion of preferredVersions) {
const preferred = javaVersions.find( const preferred = javaVersions.find(
@ -479,7 +480,7 @@ export function initMinecraftHandlers() {
username, username,
memory = 4096, memory = 4096,
baseVersion = '1.21.4', baseVersion = '1.21.4',
fabricVersion = 'fabric0.16.14', fabricVersion = '0.16.14',
packName = 'Comfort', // Название основной сборки packName = 'Comfort', // Название основной сборки
versionToLaunchOverride = '', // Возможность переопределить версию для запуска versionToLaunchOverride = '', // Возможность переопределить версию для запуска
serverIp = 'popa-popa.ru', serverIp = 'popa-popa.ru',
@ -499,6 +500,8 @@ export function initMinecraftHandlers() {
// Найти версию пакета, Fabric или базовую версию // Найти версию пакета, Fabric или базовую версию
let versionToLaunch = versionToLaunchOverride; let versionToLaunch = versionToLaunchOverride;
console.log('fabric:', `${baseVersion}-fabric${fabricVersion}`);
if (!versionToLaunch) { if (!versionToLaunch) {
if ( if (
versionsContents.includes(`${baseVersion}-fabric${fabricVersion}`) versionsContents.includes(`${baseVersion}-fabric${fabricVersion}`)
@ -519,6 +522,8 @@ export function initMinecraftHandlers() {
message: 'Поиск Java...', message: 'Поиск Java...',
}); });
console.log('Поиск Java...');
let javaPath; let javaPath;
try { try {
javaPath = await findJava(); javaPath = await findJava();
@ -540,11 +545,15 @@ export function initMinecraftHandlers() {
message: 'Получение списка версий Minecraft...', message: 'Получение списка версий Minecraft...',
}); });
console.log('Получение списка версий Minecraft...');
const versionList = await getVersionList(); const versionList = await getVersionList();
const minecraftVersion = versionList.versions.find( const minecraftVersion = versionList.versions.find(
(v) => v.id === baseVersion, (v) => v.id === baseVersion,
); );
console.log('minecraftVersion:', minecraftVersion);
if (minecraftVersion) { if (minecraftVersion) {
// Устанавливаем базовую версию Minecraft // Устанавливаем базовую версию Minecraft
event.sender.send('installation-status', { event.sender.send('installation-status', {
@ -552,11 +561,15 @@ export function initMinecraftHandlers() {
message: `Установка Minecraft ${baseVersion}...`, message: `Установка Minecraft ${baseVersion}...`,
}); });
console.log('Установка Minecraft...');
try { try {
const installMcTask = installTask(minecraftVersion, minecraftDir, { const installMcTask = installTask(minecraftVersion, minecraftDir, {
skipRevalidate: true, skipRevalidate: true,
}); });
console.log('installMcTask:', installMcTask);
await installMcTask.startAndWait({ await installMcTask.startAndWait({
onStart(task) { onStart(task) {
event.sender.send('installation-status', { event.sender.send('installation-status', {
@ -577,6 +590,7 @@ export function initMinecraftHandlers() {
}); });
}, },
onFailed(task, error) { onFailed(task, error) {
console.log('onFailed:', task, error);
console.warn( console.warn(
`Ошибка при установке ${task.path}, продолжаем:`, `Ошибка при установке ${task.path}, продолжаем:`,
error, error,
@ -594,10 +608,15 @@ export function initMinecraftHandlers() {
}, },
}); });
} catch (error) { } catch (error) {
console.warn('Ошибка при установке Minecraft, продолжаем:', error); console.log('Ошибка при установке Minecraft, продолжаем:', error);
} }
// 2. Устанавливаем Fabric // 2. Устанавливаем Fabric
console.log('Попытка установки Fabric:', {
minecraftVersion: baseVersion,
fabricVersion: fabricVersion,
minecraftDir: minecraftDir,
});
try { try {
event.sender.send('installation-status', { event.sender.send('installation-status', {
step: 'fabric-list', step: 'fabric-list',
@ -610,6 +629,12 @@ export function initMinecraftHandlers() {
message: `Установка Fabric ${fabricVersion}...`, message: `Установка Fabric ${fabricVersion}...`,
}); });
console.log('installFabric:', {
minecraftVersion: baseVersion,
fabricVersion: fabricVersion,
minecraftDir: minecraftDir,
});
await installFabric({ await installFabric({
minecraftVersion: baseVersion, minecraftVersion: baseVersion,
version: fabricVersion, // Используйте напрямую, без .version version: fabricVersion, // Используйте напрямую, без .version
@ -617,7 +642,7 @@ export function initMinecraftHandlers() {
}); });
} }
} catch (error) { } catch (error) {
console.warn('Ошибка при установке Fabric, продолжаем:', error); console.log('Ошибка при установке Fabric, продолжаем:', error);
} }
// 3. Подготовка версии и установка зависимостей // 3. Подготовка версии и установка зависимостей
@ -625,11 +650,18 @@ export function initMinecraftHandlers() {
// Используем идентификатор Fabric-версии // Используем идентификатор Fabric-версии
const fabricVersionId = `${baseVersion}-fabric${fabricVersion}`; const fabricVersionId = `${baseVersion}-fabric${fabricVersion}`;
console.log('version-parse:', fabricVersionId);
event.sender.send('installation-status', { event.sender.send('installation-status', {
step: 'version-parse', step: 'version-parse',
message: 'Подготовка версии...', message: 'Подготовка версии...',
}); });
console.log('version-parse:', {
minecraftDir: minecraftDir,
fabricVersionId: fabricVersionId,
});
resolvedVersion = await Version.parse( resolvedVersion = await Version.parse(
minecraftDir, minecraftDir,
fabricVersionId, fabricVersionId,
@ -670,6 +702,7 @@ export function initMinecraftHandlers() {
}); });
}, },
onFailed(task, error) { onFailed(task, error) {
console.log('onFailed:', task, error);
console.warn( console.warn(
`Ошибка при установке ${task.path}, продолжаем:`, `Ошибка при установке ${task.path}, продолжаем:`,
error, error,
@ -687,32 +720,38 @@ export function initMinecraftHandlers() {
}, },
}); });
} catch (error) { } catch (error) {
console.warn( console.log(
'Ошибка при загрузке ресурсов, продолжаем запуск:', 'Ошибка при загрузке ресурсов, продолжаем запуск:',
error, error,
); );
} }
} catch (error) { } catch (error) {
console.warn('Ошибка при подготовке версии, продолжаем:', error); console.log('Ошибка при подготовке версии, продолжаем:', error);
} }
} }
} catch (error) { } catch (error) {
console.warn('Произошла ошибка при подготовке Minecraft:', error); console.log('Произошла ошибка при подготовке Minecraft:', error);
} }
// Загрузка и проверка authlib-injector // Загрузка и проверка authlib-injector
const authlibPath = await ensureAuthlibInjectorExists(appPath); const authlibPath = await ensureAuthlibInjectorExists(appPath);
console.log('authlibPath:', authlibPath);
event.sender.send('installation-status', { event.sender.send('installation-status', {
step: 'authlib-injector', step: 'authlib-injector',
message: 'authlib-injector готов', message: 'authlib-injector готов',
}); });
// Запускаем Minecraft с authlib-injector для Ely.by // Запускаем Minecraft с authlib-injector для Ely.by
console.log('Запуск игры...');
event.sender.send('installation-status', { event.sender.send('installation-status', {
step: 'launch', step: 'launch',
message: 'Запуск игры...', message: 'Запуск игры...',
}); });
console.log('Запуск игры...');
// При запуске используем переданные параметры // При запуске используем переданные параметры
const packDir = path.join(versionsDir, packName); const packDir = path.join(versionsDir, packName);
@ -724,17 +763,27 @@ export function initMinecraftHandlers() {
serverConfig.port = serverPort; serverConfig.port = serverPort;
} }
console.log('packDir:', packDir);
const proc = await launch({ const proc = await launch({
gamePath: packDir, gamePath: packDir,
resourcePath: minecraftDir, resourcePath: minecraftDir,
javaPath, javaPath,
version: versionToLaunch, version: versionToLaunch,
launcherName: 'popa-popa', launcherName: 'popa-popa',
server: serverConfig, // Используем созданный объект конфигурации
extraJVMArgs: [ extraJVMArgs: [
'-Dlog4j2.formatMsgNoLookups=true', '-Dlog4j2.formatMsgNoLookups=true',
`-javaagent:${authlibPath}=ely.by`, `-javaagent:${authlibPath}=${API_BASE_URL}`,
`-Xmx${memory}M`, `-Xmx${memory}M`,
'-Dauthlibinjector.skinWhitelist=127.0.0.1,falrfg-213-87-196-173.ru.tuna.am',
'-Dauthlibinjector.debug=verbose,authlib',
'-Dauthlibinjector.legacySkinPolyfill=enabled',
'-Dauthlibinjector.mojangAntiFeatures=disabled',
'-Dcom.mojang.authlib.disableSecureProfileEndpoints=true',
],
extraMCArgs: [
'--quickPlayMultiplayer',
`${serverIp}:${serverPort || 25565}`,
], ],
// Используем данные аутентификации Yggdrasil // Используем данные аутентификации Yggdrasil
accessToken, accessToken,
@ -752,6 +801,8 @@ export function initMinecraftHandlers() {
console.error(`Minecraft stderr: ${data}`); console.error(`Minecraft stderr: ${data}`);
}); });
console.log('Запуск игры...');
return { success: true, pid: proc.pid }; return { success: true, pid: proc.pid };
} catch (error) { } catch (error) {
console.error('Ошибка при запуске Minecraft:', error); console.error('Ошибка при запуске Minecraft:', error);
@ -804,6 +855,106 @@ export function initMinecraftHandlers() {
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ПРОБЛЕМА: У вас два обработчика для одного и того же канала 'get-installed-versions'
// РЕШЕНИЕ: Объединим логику в один обработчик, а из второго обработчика вызовем функцию getInstalledVersions
// Сначала создаем общую функцию для получения установленных версий
function getInstalledVersions() {
try {
const appPath = path.dirname(app.getPath('exe'));
const minecraftDir = path.join(appPath, '.minecraft');
const versionsDir = path.join(minecraftDir, 'versions');
if (!fs.existsSync(versionsDir)) {
return { success: true, versions: [] };
}
const items = fs.readdirSync(versionsDir);
const versions = [];
for (const item of items) {
const versionPath = path.join(versionsDir, item);
if (fs.statSync(versionPath).isDirectory()) {
// Проверяем, есть ли конфигурация для пакета
const versionJsonPath = path.join(versionPath, `${item}.json`);
let versionInfo = {
id: item,
name: item,
version: item,
};
if (fs.existsSync(versionJsonPath)) {
try {
const versionData = JSON.parse(
fs.readFileSync(versionJsonPath, 'utf8'),
);
versionInfo.version = versionData.id || item;
} catch (error) {
console.warn(`Ошибка при чтении файла версии ${item}:`, error);
}
}
versions.push(versionInfo);
}
}
return { success: true, versions };
} catch (error) {
console.error('Ошибка при получении установленных версий:', error);
return { success: false, error: error.message, versions: [] };
}
}
// Регистрируем обработчик для get-installed-versions
ipcMain.handle('get-installed-versions', async () => {
return getInstalledVersions();
});
// Обработчик get-available-versions использует функцию getInstalledVersions
ipcMain.handle('get-available-versions', async (event, { gistUrl }) => {
try {
// Используем URL из параметров или значение по умолчанию
const url =
gistUrl ||
'https://gist.githubusercontent.com/DIKER0K/06cd12fb3a4d08b1f0f8c763a7d05e06/raw/versions.json';
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch versions from Gist: ${response.status} ${response.statusText}`,
);
}
const versions = await response.json();
// Получаем уже установленные версии
const installedResult = getInstalledVersions();
const installedVersions = installedResult.success
? installedResult.versions
: [];
// Добавляем флаг installed к каждой версии
const versionsWithInstallStatus = versions.map((version: any) => {
const isInstalled = installedVersions.some(
(installed: any) => installed.id === version.id,
);
return {
...version,
installed: isInstalled,
};
});
return {
success: true,
versions: versionsWithInstallStatus,
};
} catch (error) {
console.error('Ошибка при получении доступных версий:', error);
return { success: false, error: error.message, versions: [] };
}
});
} }
// Добавляем обработчики IPC для аутентификации // Добавляем обработчики IPC для аутентификации
@ -824,22 +975,18 @@ export function initAuthHandlers() {
}); });
// Валидация токена // Валидация токена
ipcMain.handle('validate-token', async (event, accessToken) => { ipcMain.handle(
try { 'validate-token',
const clientToken = JSON.parse( async (event, { accessToken, clientToken }) => {
fs.readFileSync( try {
path.join(app.getPath('userData'), 'config.json'), const valid = await authService.validate(accessToken, clientToken);
'utf8', return { valid };
), } catch (error) {
).clientToken; console.error('Ошибка валидации токена:', error);
return { valid: false };
const valid = await authService.validate(accessToken, clientToken); }
return { valid }; },
} catch (error) { );
console.error('Ошибка валидации токена:', error);
return { valid: false };
}
});
// Обновление токена // Обновление токена
ipcMain.handle( ipcMain.handle(
@ -979,3 +1126,57 @@ export function initPackConfigHandlers() {
} }
}); });
} }
// Добавляем после обработчика get-available-versions
ipcMain.handle('get-version-config', async (event, { versionId }) => {
try {
const appPath = path.dirname(app.getPath('exe'));
const minecraftDir = path.join(appPath, '.minecraft');
const versionsDir = path.join(minecraftDir, 'versions');
const versionPath = path.join(versionsDir, versionId);
// Проверяем существование директории версии
if (!fs.existsSync(versionPath)) {
return { success: false, error: `Версия ${versionId} не найдена` };
}
// Проверяем конфигурационный файл версии
const configPath = path.join(versionPath, 'popa-launcher-config.json');
// Определяем базовые настройки по умолчанию
let config = {
downloadUrl: '',
apiReleaseUrl: '',
versionFileName: `${versionId}_version.txt`,
packName: versionId,
memory: 4096,
baseVersion: '1.21.4',
serverIp: 'popa-popa.ru',
fabricVersion: '0.16.14',
preserveFiles: ['popa-launcher-config.json'],
};
// Если это Comfort, используем настройки по умолчанию
if (versionId === 'Comfort') {
config.downloadUrl =
'https://github.com/DIKER0K/Comfort/releases/latest/download/Comfort.zip';
config.apiReleaseUrl =
'https://api.github.com/repos/DIKER0K/Comfort/releases/latest';
}
// Если есть конфигурационный файл, загружаем из него
if (fs.existsSync(configPath)) {
try {
const savedConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config = { ...config, ...savedConfig };
} catch (error) {
console.warn(`Ошибка чтения конфигурации ${versionId}:`, error);
}
}
return { success: true, config };
} catch (error) {
console.error('Ошибка получения настроек версии:', error);
return { success: false, error: error.message };
}
});

View File

@ -11,7 +11,9 @@ export type Channels =
| 'save-pack-config' | 'save-pack-config'
| 'load-pack-config' | 'load-pack-config'
| 'update-available' | 'update-available'
| 'install-update'; | 'install-update'
| 'get-installed-versions'
| 'get-available-versions';
const electronHandler = { const electronHandler = {
ipcRenderer: { ipcRenderer: {

View File

@ -22,6 +22,8 @@ body {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 0;
margin: 0;
} }
p { p {
@ -47,3 +49,11 @@ h4 {
h5 { h5 {
font-family: 'Benzin-Bold' !important; font-family: 'Benzin-Bold' !important;
} }
h6 {
font-family: 'Benzin-Bold' !important;
}
span {
font-family: 'Benzin-Bold' !important;
}

View File

@ -3,6 +3,7 @@ import {
Routes, Routes,
Route, Route,
Navigate, Navigate,
useNavigate,
} from 'react-router-dom'; } from 'react-router-dom';
import Login from './pages/Login'; import Login from './pages/Login';
import LaunchPage from './pages/LaunchPage'; import LaunchPage from './pages/LaunchPage';
@ -10,21 +11,13 @@ import { ReactNode, useEffect, useState } from 'react';
import './App.css'; import './App.css';
import TopBar from './components/TopBar'; import TopBar from './components/TopBar';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import MinecraftBackround from './components/MinecraftBackround'; import MinecraftBackground from './components/MinecraftBackground';
import { Notifier } from './components/Notifier'; import { Notifier } from './components/Notifier';
import { VersionsExplorer } from './pages/VersionsExplorer';
// Переместите launchOptions сюда, вне компонентов import Profile from './pages/Profile';
const launchOptions = { import Shop from './pages/Shop';
downloadUrl: import Marketplace from './pages/Marketplace';
'https://github.com/DIKER0K/Comfort/releases/latest/download/Comfort.zip', import { Registration } from './pages/Registration';
apiReleaseUrl: 'https://api.github.com/repos/DIKER0K/Comfort/releases/latest',
versionFileName: 'comfort_version.txt',
packName: 'Comfort',
memory: 4096,
baseVersion: '1.21.4',
serverIp: 'popa-popa.ru',
fabricVersion: '0.16.14', // Уберите префикс "fabric"
};
const AuthCheck = ({ children }: { children: ReactNode }) => { const AuthCheck = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null); const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
@ -37,7 +30,10 @@ const AuthCheck = ({ children }: { children: ReactNode }) => {
const config = JSON.parse(savedConfig); const config = JSON.parse(savedConfig);
if (config.accessToken) { if (config.accessToken) {
// Можно добавить дополнительную проверку токена // Можно добавить дополнительную проверку токена
const isValid = await validateToken(config.accessToken); const isValid = await validateToken(
config.accessToken,
config.clientToken,
);
setIsAuthenticated(isValid); setIsAuthenticated(isValid);
return; return;
} }
@ -52,17 +48,39 @@ const AuthCheck = ({ children }: { children: ReactNode }) => {
checkAuth(); checkAuth();
}, []); }, []);
const validateToken = async (token: string) => { const validateToken = async (accessToken: string, clientToken: string) => {
try { try {
const response = await fetch('https://authserver.ely.by/auth/validate', { // Используем IPC для валидации токена через main процесс
method: 'POST', const result = await window.electron.ipcRenderer.invoke(
headers: { 'validate-token',
'Content-Type': 'application/json', { accessToken, clientToken },
}, );
body: JSON.stringify({ accessToken: token }),
}); // Если токен недействителен, очищаем сохраненные данные в localStorage
return response.ok; if (!result.valid) {
console.log(
'Токен недействителен, очищаем данные авторизации из localStorage',
);
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
const config = JSON.parse(savedConfig);
// Сохраняем только логин и другие настройки, но удаляем токены
const cleanedConfig = {
username: config.username,
memory: config.memory || 4096,
comfortVersion: config.comfortVersion || '',
password: '', // Очищаем пароль для безопасности
};
localStorage.setItem(
'launcher_config',
JSON.stringify(cleanedConfig),
);
}
}
return result.valid;
} catch (error) { } catch (error) {
console.error('Ошибка проверки токена:', error);
return false; return false;
} }
}; };
@ -79,6 +97,17 @@ const App = () => {
const handleRegister = () => { const handleRegister = () => {
window.open('https://account.ely.by/register', '_blank'); window.open('https://account.ely.by/register', '_blank');
}; };
const [username, setUsername] = useState<string | null>(null);
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
const config = JSON.parse(savedConfig);
if (config.username) {
setUsername(config.username);
}
}
}, []);
return ( return (
<Router> <Router>
@ -91,18 +120,52 @@ const App = () => {
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
overflowX: 'hidden',
}} }}
> >
<MinecraftBackround /> <MinecraftBackground />
<TopBar onRegister={handleRegister} /> <TopBar onRegister={handleRegister} username={username || ''} />
<Notifier /> <Notifier />
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/registration" element={<Registration />} />
<Route <Route
path="/" path="/"
element={ element={
<AuthCheck> <AuthCheck>
<LaunchPage launchOptions={launchOptions} /> <VersionsExplorer />
</AuthCheck>
}
/>
<Route
path="/launch/:versionId"
element={
<AuthCheck>
<LaunchPage />
</AuthCheck>
}
/>
<Route
path="/profile"
element={
<AuthCheck>
<Profile />
</AuthCheck>
}
/>
<Route
path="/shop"
element={
<AuthCheck>
<Shop />
</AuthCheck>
}
/>
<Route
path="/marketplace"
element={
<AuthCheck>
<Marketplace />
</AuthCheck> </AuthCheck>
} }
/> />

628
src/renderer/api.ts Normal file
View File

@ -0,0 +1,628 @@
export const API_BASE_URL = 'https://minecraft.api.popa-popa.ru';
export interface Player {
uuid: string;
username: string;
skin_url: string;
cloak_url: string;
coins: number;
is_active: boolean;
created_at: string;
}
export interface CoinsResponse {
username: string;
coins: number;
total_time_played: {
seconds: number;
formatted: string;
};
}
export interface Cape {
cape_id: string;
cape_name: string;
cape_description: string;
image_url: string;
purchased_at: string;
is_active: boolean;
}
export type CapesResponse = Cape[];
export interface StoreCape {
id: string;
name: string;
description: string;
price: number;
image_url: string;
}
export type StoreCapesResponse = StoreCape[];
export interface ApiError {
message: string;
details?: string;
}
export interface Server {
id: string;
name: string;
ip: string;
port: number;
description: string;
online_players: number;
max_players: number;
last_activity: string;
}
export interface ActiveServersResponse {
servers: Server[];
}
export interface OnlinePlayersResponse {
server: {
id: string;
name: string;
};
online_players: {
// Это массив объектов, а не один объект
username: string;
uuid: string;
online_since: string;
}[]; // Добавьте [] здесь чтобы указать, что это массив
count: number;
}
export interface MarketplaceResponse {
items: [
{
_id: string;
id: string;
material: string;
amount: number;
price: number;
seller_name: string;
server_ip: string;
display_name: string | null;
lore: string | null;
enchants: string | null;
item_data: {
slot: number;
material: string;
amount: number;
};
created_at: string;
},
];
total: number;
page: number;
pages: number;
}
export interface MarketplaceItemResponse {
_id: string;
id: string;
material: string;
amount: number;
price: number;
seller_name: string;
server_ip: string;
display_name: string | null;
lore: string | null;
enchants: string | null;
item_data: {
slot: number;
material: string;
amount: number;
};
created_at: string;
}
export interface SellItemResponse {
message: string;
}
export interface BuyItemResponse {
message: string;
}
export interface PlayerInventoryResponse {
status: string;
request_id: string;
}
export interface PlayerInventory {
status: string;
result: {
player_name: string;
server_ip: string;
inventory_data: PlayerInventoryItem[];
};
}
export interface PlayerInventoryItem {
slot: number;
material: string;
amount: number;
enchants: {
[key: string]: number;
};
}
export interface MarketplaceOperation {
id: string;
type: 'sell' | 'buy';
player_name: string;
slot_index?: number;
amount?: number;
price: number;
server_ip: string;
status: 'pending' | 'completed' | 'failed';
item_id?: string;
error?: string;
created_at: string;
item_data?: any;
}
export interface OperationsResponse {
operations: MarketplaceOperation[];
}
export interface RegisterUserResponse {
status: string;
uuid: string;
}
export interface GenerateVerificationCodeResponse {
status: string;
code: string;
}
export interface VerificationStatusResponse {
is_verified: boolean;
}
export async function getVerificationStatus(
username: string,
): Promise<VerificationStatusResponse> {
const response = await fetch(
`${API_BASE_URL}/auth/verification_status/${username}`,
);
if (!response.ok) {
throw new Error('Не удалось получить статус верификации');
}
return await response.json();
}
export async function generateVerificationCode(
username: string,
): Promise<GenerateVerificationCodeResponse> {
const response = await fetch(
`${API_BASE_URL}/auth/generate_code?username=${username}`,
{
method: 'POST',
},
);
if (!response.ok) {
throw new Error('Не удалось сгенерировать код верификации');
}
return await response.json();
}
export async function registerUser(
username: string,
password: string,
): Promise<RegisterUserResponse> {
const response = await fetch(`${API_BASE_URL}/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
}),
});
if (!response.ok) {
throw new Error('Не удалось зарегистрировать пользователя');
}
return await response.json();
}
export async function getPlayerInventory(
request_id: string,
): Promise<PlayerInventory> {
const response = await fetch(
`${API_BASE_URL}/api/server/inventory/${request_id}`,
);
if (!response.ok) {
throw new Error('Не удалось получить инвентарь игрока');
}
return await response.json();
}
export async function RequestPlayerInventory(
server_ip: string,
player_name: string,
): Promise<PlayerInventoryResponse> {
const response = await fetch(`${API_BASE_URL}/api/server/inventory`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
server_ip: server_ip,
player_name: player_name,
}),
});
if (!response.ok) {
throw new Error('Не удалось получить инвентарь игрока');
}
return await response.json();
}
export async function buyItem(
buyer_username: string,
item_id: string,
): Promise<{ status: string; operation_id: string; message: string }> {
const response = await fetch(
`${API_BASE_URL}/api/marketplace/items/buy/${item_id}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: buyer_username,
}),
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.message || errorData.detail || 'Не удалось купить предмет',
);
}
return await response.json();
}
export async function confirmMarketplaceOperation(
operation_id: string,
status: string = 'success',
error?: string,
): Promise<{ status: string }> {
const response = await fetch(
`${API_BASE_URL}/api/marketplace/operations/confirm`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
operation_id,
status,
error,
}),
},
);
if (!response.ok) {
throw new Error('Не удалось подтвердить операцию');
}
return await response.json();
}
export async function submitItemDetails(
operation_id: string,
item_data: any,
): Promise<{ status: string }> {
const response = await fetch(
`${API_BASE_URL}/api/marketplace/items/details`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
operation_id,
item_data,
}),
},
);
if (!response.ok) {
throw new Error('Не удалось отправить данные предмета');
}
return await response.json();
}
export async function sellItem(
username: string,
slot_index: number,
amount: number,
price: number,
server_ip: string,
): Promise<{ status: string; operation_id: string }> {
const response = await fetch(`${API_BASE_URL}/api/marketplace/items/sell`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
slot_index,
amount,
price,
server_ip,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.message ||
errorData.detail ||
'Не удалось выставить предмет на продажу',
);
}
return await response.json();
}
export async function fetchMarketplaceItem(
item_id: string,
): Promise<MarketplaceItemResponse> {
const response = await fetch(
`${API_BASE_URL}/api/marketplace/items/${item_id}`,
);
if (!response.ok) {
throw new Error('Не удалось получить рынок');
}
return await response.json();
}
export async function fetchMarketplace(
server_ip: string,
page: number,
limit: number,
): Promise<MarketplaceResponse> {
// Создаем URL с параметрами запроса
const url = new URL(`${API_BASE_URL}/api/marketplace/items`);
url.searchParams.append('server_ip', server_ip);
url.searchParams.append('page', page.toString());
url.searchParams.append('limit', limit.toString());
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error('Не удалось получить предметы рынка');
}
return await response.json();
}
// Исправьте тип возвращаемого значения
export async function fetchActiveServers(): Promise<Server[]> {
const response = await fetch(`${API_BASE_URL}/api/pranks/servers`);
if (!response.ok) {
throw new Error('Не удалось получить активные сервера');
}
return await response.json();
}
export async function fetchOnlinePlayers(
server_id: string,
): Promise<OnlinePlayersResponse> {
const response = await fetch(
`${API_BASE_URL}/api/pranks/servers/${server_id}/players`,
);
if (!response.ok) {
throw new Error('Не удалось получить онлайн игроков');
}
return await response.json();
}
// Получение информации о игроке
export async function fetchPlayer(uuid: string): Promise<Player> {
try {
const response = await fetch(`${API_BASE_URL}/users/${uuid}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Ошибка получения данных игрока');
}
return await response.json();
} catch (error) {
console.error('API ошибка:', error);
throw error;
}
}
export async function fetchCoins(username: string): Promise<CoinsResponse> {
try {
const response = await fetch(`${API_BASE_URL}/users/${username}/coins`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Ошибка получения данных игрока');
}
return await response.json();
} catch (error) {
console.error('API ошибка:', error);
throw error;
}
}
export async function fetchCapes(username: string): Promise<CapesResponse> {
try {
const response = await fetch(
`${API_BASE_URL}/store/user/${username}/capes`,
);
if (!response.ok) {
return []; // Если плащи не найдены, возвращаем пустой массив
}
return await response.json();
} catch (error) {
console.error('API ошибка:', error);
return []; // В случае ошибки возвращаем пустой массив
}
}
export async function purchaseCape(
username: string,
cape_id: string,
): Promise<void> {
// Создаем URL с query-параметрами
const url = new URL(`${API_BASE_URL}/store/purchase/cape`);
url.searchParams.append('username', username);
url.searchParams.append('cape_id', cape_id);
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
// Не нужно отправлять тело запроса
// body: JSON.stringify({
// username: username,
// cape_id: cape_id,
// }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.message ||
errorData.detail?.toString() ||
'Не удалось купить плащ',
);
}
}
export async function fetchCapesStore(): Promise<StoreCape[]> {
try {
const response = await fetch(`${API_BASE_URL}/store/capes`);
if (!response.ok) {
return [];
}
return await response.json();
} catch (error) {
console.error('API ошибка:', error);
return [];
}
}
export async function activateCape(
username: string,
cape_id: string,
): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/store/user/${username}/capes/activate/${cape_id}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
cape_id: cape_id,
}),
},
);
if (!response.ok) {
throw new Error('Не удалось активировать плащ');
}
}
export async function deactivateCape(
username: string,
cape_id: string,
): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/store/user/${username}/capes/deactivate/${cape_id}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
cape_id: cape_id,
}),
},
);
if (!response.ok) {
throw new Error('Не удалось деактивировать плащ');
}
}
// Загрузка скина
export async function uploadSkin(
username: string,
skinFile: File,
skinModel: string,
): Promise<void> {
const savedConfig = localStorage.getItem('launcher_config');
let accessToken = '';
let clientToken = '';
if (savedConfig) {
const config = JSON.parse(savedConfig);
accessToken = config.accessToken || '';
clientToken = config.clientToken || '';
}
const formData = new FormData();
formData.append('skin_file', skinFile);
formData.append('skin_model', skinModel);
formData.append('accessToken', accessToken);
formData.append('clientToken', clientToken);
const response = await fetch(`${API_BASE_URL}/user/${username}/skin`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Не удалось загрузить скин');
}
}
// Получение токенов из локального хранилища
export function getAuthTokens(): { accessToken: string; clientToken: string } {
const savedConfig = localStorage.getItem('launcher_config');
let accessToken = '';
let clientToken = '';
if (savedConfig) {
const config = JSON.parse(savedConfig);
accessToken = config.accessToken || '';
clientToken = config.clientToken || '';
}
return { accessToken, clientToken };
}
// Загрузка плаща
export async function uploadCape(
username: string,
capeFile: File,
): Promise<void> {
const { accessToken, clientToken } = getAuthTokens();
const formData = new FormData();
formData.append('cape_file', capeFile);
formData.append('accessToken', accessToken);
formData.append('clientToken', clientToken);
const response = await fetch(`${API_BASE_URL}/user/${username}/cape`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Не удалось загрузить плащ');
}
}

View File

@ -0,0 +1,127 @@
// src/renderer/components/CapeCard.tsx
import React from 'react';
import {
Card,
CardMedia,
CardContent,
Typography,
CardActions,
Button,
Tooltip,
Box,
Chip,
} from '@mui/material';
// Тип для плаща с необязательными полями для обоих вариантов использования
export interface CapeCardProps {
cape: {
cape_id?: string;
id?: string;
cape_name?: string;
name?: string;
cape_description?: string;
description?: string;
image_url: string;
is_active?: boolean;
price?: number;
purchased_at?: string;
};
mode: 'profile' | 'shop';
onAction: (capeId: string) => void;
actionDisabled?: boolean;
}
export default function CapeCard({
cape,
mode,
onAction,
actionDisabled = false,
}: CapeCardProps) {
// Определяем текст и цвет кнопки в зависимости от режима
const getActionButton = () => {
if (mode === 'shop') {
return {
text: 'Купить',
color: 'primary',
};
} else {
// Профиль
return cape.is_active
? { text: 'Снять', color: 'error' }
: { text: 'Надеть', color: 'success' };
}
};
const actionButton = getActionButton();
// В функции компонента добавьте нормализацию данных
const capeId = cape.cape_id || cape.id || '';
const capeName = cape.cape_name || cape.name || '';
const capeDescription = cape.cape_description || cape.description || '';
return (
<Tooltip arrow title={capeDescription}>
<Card
sx={{
bgcolor: 'rgba(255, 255, 255, 0.05)',
width: 200,
overflow: 'hidden',
position: 'relative', // для позиционирования ценника
}}
>
{/* Ценник для магазина */}
{mode === 'shop' && cape.price !== undefined && (
<Chip
label={`${cape.price} коинов`}
sx={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 2,
bgcolor: 'rgba(0, 0, 0, 0.7)',
color: 'white',
fontWeight: 'bold',
}}
/>
)}
<CardMedia
component="img"
image={cape.image_url}
alt={capeName}
sx={{
display: 'block',
width: '100%',
transform: 'scale(2.9) translateX(66px) translateY(32px)',
imageRendering: 'pixelated',
}}
/>
<CardContent sx={{ bgcolor: 'rgba(255, 255, 255, 0.05)', pt: '9vh' }}>
<Typography sx={{ color: 'white' }}>{capeName}</Typography>
</CardContent>
<CardActions sx={{ display: 'flex', justifyContent: 'center' }}>
<Button
variant="contained"
color={actionButton.color as 'primary' | 'success' | 'error'}
onClick={() => onAction(capeId)}
disabled={actionDisabled}
sx={{
borderRadius: '20px',
p: '5px 25px',
color: 'white',
backgroundColor: 'rgb(0, 134, 0)',
'&:hover': {
backgroundColor: 'rgba(0, 134, 0, 0.5)',
},
fontFamily: 'Benzin-Bold',
}}
>
{actionButton.text}
</Button>
</CardActions>
</Card>
</Tooltip>
);
}

View File

@ -11,23 +11,78 @@ interface AuthFormProps {
const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => { const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.5vw' }}> <Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1.5vw',
alignItems: 'center',
}}
>
<Typography variant="h6">Логин</Typography>
<TextField <TextField
required required
name="username" name="username"
label="Введите ник"
variant="outlined" variant="outlined"
value={config.username} value={config.username}
onChange={handleInputChange} onChange={handleInputChange}
sx={{
width: '100%',
// '& .MuiFormLabel-root': {
// color: 'white',
// },
'& .MuiInputBase-input': {
color: 'white',
},
'& .MuiInput-underline:after': {
borderBottomColor: '#B2BAC2',
},
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: '#E0E3E7',
color: 'white',
},
'&:hover fieldset': {
borderColor: '#B2BAC2',
},
'&.Mui-focused fieldset': {
borderColor: '#6F7E8C',
},
},
}}
/> />
<Typography variant="h6">Пароль</Typography>
<TextField <TextField
required required
type="password" type="password"
name="password" name="password"
label="Введите пароль"
variant="outlined" variant="outlined"
value={config.password} value={config.password}
onChange={handleInputChange} onChange={handleInputChange}
sx={{
width: '100%',
// '& .MuiFormLabel-root': {
// color: 'white',
// },
'& .MuiInputBase-input': {
color: 'white',
},
'& .MuiInput-underline:after': {
borderBottomColor: '#B2BAC2',
},
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: '#E0E3E7',
color: 'white',
},
'&:hover fieldset': {
borderColor: '#B2BAC2',
},
'&.Mui-focused fieldset': {
borderColor: '#6F7E8C',
},
},
}}
/> />
<Button onClick={onLogin} variant="contained"> <Button onClick={onLogin} variant="contained">
Войти Войти

View File

@ -1,7 +1,7 @@
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import heart from '../../../assets/images/heart.svg'; import heart from '../../../assets/images/heart.svg';
export default function MinecraftBackround() { export default function MinecraftBackground() {
return ( return (
<Box <Box
sx={{ sx={{

View File

@ -0,0 +1,355 @@
// src/renderer/components/PlayerInventory.tsx
import { useEffect, useState } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardMedia,
CardContent,
Button,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Alert,
} from '@mui/material';
import {
RequestPlayerInventory,
getPlayerInventory,
sellItem,
PlayerInventoryItem,
} from '../api';
interface PlayerInventoryProps {
username: string;
serverIp: string;
onSellSuccess?: () => void; // Callback для обновления маркетплейса после продажи
}
export default function PlayerInventory({
username,
serverIp,
onSellSuccess,
}: PlayerInventoryProps) {
const [loading, setLoading] = useState<boolean>(false);
const [inventoryItems, setInventoryItems] = useState<PlayerInventoryItem[]>(
[],
);
const [error, setError] = useState<string | null>(null);
const [sellDialogOpen, setSellDialogOpen] = useState<boolean>(false);
const [selectedItem, setSelectedItem] = useState<PlayerInventoryItem | null>(
null,
);
const [price, setPrice] = useState<number>(0);
const [amount, setAmount] = useState<number>(1);
const [sellLoading, setSellLoading] = useState<boolean>(false);
const [sellError, setSellError] = useState<string | null>(null);
// Функция для запроса инвентаря игрока
const fetchPlayerInventory = async () => {
try {
setLoading(true);
setError(null);
// Сначала делаем запрос на получение идентификатора запроса инвентаря
const inventoryRequest = await RequestPlayerInventory(serverIp, username);
const requestId = inventoryRequest.request_id;
// Затем начинаем опрашивать API для получения результата
let inventoryData = null;
let attempts = 0;
const maxAttempts = 10; // Максимальное количество попыток
while (!inventoryData && attempts < maxAttempts) {
attempts++;
try {
// Пауза перед следующим запросом
await new Promise((resolve) => setTimeout(resolve, 1000));
// Запрашиваем состояние инвентаря
const response = await getPlayerInventory(requestId);
// Если инвентарь загружен, сохраняем его
if (response.status === 'completed') {
inventoryData = response.result.inventory_data;
break;
}
} catch (e) {
console.log('Ожидание завершения запроса инвентаря...');
}
}
if (inventoryData) {
setInventoryItems(inventoryData);
} else {
setError('Не удалось получить инвентарь. Попробуйте еще раз.');
}
} catch (e) {
console.error('Ошибка при получении инвентаря:', e);
setError('Произошла ошибка при загрузке инвентаря.');
} finally {
setLoading(false);
}
};
// Открываем диалог для продажи предмета
const handleOpenSellDialog = (item: PlayerInventoryItem) => {
setSelectedItem(item);
setAmount(1);
setPrice(0);
setSellError(null);
setSellDialogOpen(true);
};
// Закрываем диалог
const handleCloseSellDialog = () => {
setSellDialogOpen(false);
setSelectedItem(null);
};
// Выставляем предмет на продажу
const handleSellItem = async () => {
if (!selectedItem) return;
try {
setSellLoading(true);
setSellError(null);
// Проверяем валидность введенных данных
if (price <= 0) {
setSellError('Цена должна быть больше 0');
return;
}
if (amount <= 0 || amount > selectedItem.amount) {
setSellError(`Количество должно быть от 1 до ${selectedItem.amount}`);
return;
}
// Отправляем запрос на продажу
const result = await sellItem(
username,
selectedItem.slot,
amount,
price,
serverIp,
);
// Проверяем статус операции
if (result.status === 'pending') {
// Закрываем диалог и обновляем инвентарь
handleCloseSellDialog();
// Показываем уведомление о том, что операция обрабатывается
// setNotification({ // Assuming setNotification is available in the context
// open: true,
// message: 'Предмет выставляется на продажу. Это может занять некоторое время.',
// type: 'info'
// });
// Через 5 секунд обновляем инвентарь
setTimeout(() => {
fetchPlayerInventory();
// Вызываем callback для обновления маркетплейса
if (onSellSuccess) {
onSellSuccess();
}
}, 5000);
}
} catch (e) {
console.error('Ошибка при продаже предмета:', e);
setSellError('Произошла ошибка при продаже предмета.');
} finally {
setSellLoading(false);
}
};
// Загружаем инвентарь при монтировании компонента
useEffect(() => {
fetchPlayerInventory();
}, [username, serverIp]);
// Получаем отображаемое имя предмета
const getItemDisplayName = (material: string) => {
return material
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (l) => l.toUpperCase());
};
return (
<Box sx={{ mt: 4 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="h5" color="white">
Ваш инвентарь
</Typography>
<Button
variant="outlined"
onClick={fetchPlayerInventory}
disabled={loading}
>
Обновить
</Button>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
<CircularProgress />
</Box>
) : (
<>
{inventoryItems.length === 0 ? (
<Typography
variant="body1"
color="white"
sx={{ textAlign: 'center', my: 4 }}
>
Ваш инвентарь пуст или не удалось загрузить предметы.
</Typography>
) : (
<Grid container spacing={2}>
{inventoryItems.map((item) =>
item.material !== 'AIR' && item.amount > 0 ? (
<Grid item xs={6} sm={4} md={3} lg={2} key={item.slot}>
<Card
sx={{
bgcolor: 'rgba(255, 255, 255, 0.05)',
cursor: 'pointer',
transition: 'transform 0.2s',
'&:hover': { transform: 'scale(1.03)' },
}}
onClick={() => handleOpenSellDialog(item)}
>
<CardMedia
component="img"
sx={{
height: 100,
objectFit: 'contain',
bgcolor: 'rgba(0, 0, 0, 0.2)',
p: 1,
imageRendering: 'pixelated',
}}
image={`/minecraft/${item.material.toLowerCase()}.png`}
alt={item.material}
/>
<CardContent sx={{ p: 1 }}>
<Typography variant="body2" color="white" noWrap>
{getItemDisplayName(item.material)}
</Typography>
<Typography variant="body2" color="white">
x{item.amount}
</Typography>
{Object.keys(item.enchants || {}).length > 0 && (
<Typography
variant="caption"
color="secondary"
sx={{ display: 'block' }}
>
Зачарования: {Object.keys(item.enchants).length}
</Typography>
)}
</CardContent>
</Card>
</Grid>
) : null,
)}
</Grid>
)}
</>
)}
{/* Диалог для продажи предмета */}
<Dialog open={sellDialogOpen} onClose={handleCloseSellDialog}>
<DialogTitle>Продать предмет</DialogTitle>
<DialogContent>
{selectedItem && (
<Box sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CardMedia
component="img"
sx={{
width: 50,
height: 50,
objectFit: 'contain',
mr: 2,
}}
image={`/items/${selectedItem.material.toLowerCase()}.png`}
alt={selectedItem.material}
/>
<Typography variant="h6">
{getItemDisplayName(selectedItem.material)}
</Typography>
</Box>
<Typography variant="body2" gutterBottom>
Всего доступно: {selectedItem.amount}
</Typography>
<TextField
label="Количество"
type="number"
fullWidth
margin="dense"
value={amount}
onChange={(e) =>
setAmount(
Math.min(
parseInt(e.target.value) || 0,
selectedItem.amount,
),
)
}
inputProps={{ min: 1, max: selectedItem.amount }}
/>
<TextField
label="Цена (за всё)"
type="number"
fullWidth
margin="dense"
value={price}
onChange={(e) => setPrice(parseInt(e.target.value) || 0)}
inputProps={{ min: 1 }}
/>
{sellError && (
<Alert severity="error" sx={{ mt: 2 }}>
{sellError}
</Alert>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseSellDialog}>Отмена</Button>
<Button
onClick={handleSellItem}
variant="contained"
color="primary"
disabled={sellLoading}
>
{sellLoading ? <CircularProgress size={24} /> : 'Продать'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@ -104,10 +104,7 @@ const ServerStatus = ({
? ?
</Avatar> </Avatar>
)} )}
{serverStatus.error ? (
{serverStatus.loading ? (
<CircularProgress size={20} />
) : serverStatus.error ? (
<Typography color="error">Ошибка загрузки</Typography> <Typography color="error">Ошибка загрузки</Typography>
) : ( ) : (
<Typography sx={{ fontWeight: 'bold' }}> <Typography sx={{ fontWeight: 'bold' }}>

View File

@ -0,0 +1,92 @@
import { Box, Typography, Button, Modal } from '@mui/material';
import React from 'react';
import MemorySlider from '../Login/MemorySlider';
import FilesSelector from '../FilesSelector';
interface SettingsModalProps {
open: boolean;
onClose: () => void;
config: {
memory: number;
preserveFiles: string[];
};
onConfigChange: (newConfig: {
memory: number;
preserveFiles: string[];
}) => void;
packName: string;
onSave: () => void;
}
const SettingsModal = ({
open,
onClose,
config,
onConfigChange,
packName,
onSave,
}: SettingsModalProps) => {
return (
<Modal
open={open}
onClose={onClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
background:
'linear-gradient(-242.94deg, #000000 39.07%, #3b4187 184.73%)',
border: '2px solid #000',
boxShadow: 24,
p: 4,
borderRadius: '3vw',
gap: '1vh',
display: 'flex',
flexDirection: 'column',
}}
>
<Typography id="modal-modal-title" variant="body1" component="h2">
Файлы и папки, которые будут сохранены после переустановки сборки
</Typography>
<FilesSelector
packName={packName}
initialSelected={config.preserveFiles}
onSelectionChange={(selected) => {
onConfigChange({ ...config, preserveFiles: selected });
}}
/>
<Typography variant="body1" sx={{ color: 'white' }}>
Оперативная память выделенная для Minecraft
</Typography>
<MemorySlider
memory={config.memory}
onChange={(e, value) => {
onConfigChange({ ...config, memory: value as number });
}}
/>
<Button
variant="contained"
color="success"
onClick={() => {
onSave();
onClose();
}}
sx={{
borderRadius: '3vw',
fontFamily: 'Benzin-Bold',
}}
>
Сохранить
</Button>
</Box>
</Modal>
);
};
export default SettingsModal;

View File

@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react';
interface SkinViewerProps {
width?: number;
height?: number;
skinUrl?: string;
capeUrl?: string;
walkingSpeed?: number;
autoRotate?: boolean;
}
export default function SkinViewer({
width = 300,
height = 400,
skinUrl,
capeUrl,
walkingSpeed = 0.5,
autoRotate = true,
}: SkinViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewerRef = useRef<any>(null);
useEffect(() => {
if (!canvasRef.current) return;
// Используем динамический импорт для обхода проблемы ESM/CommonJS
const initSkinViewer = async () => {
try {
const skinview3d = await import('skinview3d');
// Создаем просмотрщик скина по документации
const viewer = new skinview3d.SkinViewer({
canvas: canvasRef.current,
width,
height,
skin: skinUrl || undefined,
model: 'auto-detect',
cape: capeUrl || undefined,
});
// Настраиваем вращение
viewer.autoRotate = autoRotate;
// Настраиваем анимацию ходьбы
viewer.animation = new skinview3d.WalkingAnimation();
viewer.animation.speed = walkingSpeed;
// Сохраняем экземпляр для очистки
viewerRef.current = viewer;
} catch (error) {
console.error('Ошибка при инициализации skinview3d:', error);
}
};
initSkinViewer();
// Очистка при размонтировании
return () => {
if (viewerRef.current) {
viewerRef.current.dispose();
}
};
}, [width, height, skinUrl, capeUrl, walkingSpeed, autoRotate]);
return (
<canvas
ref={canvasRef}
width={width}
height={height}
style={{ display: 'block' }}
/>
);
}

View File

@ -1,7 +1,10 @@
import { Box, Button, Typography } from '@mui/material'; import { Box, Button, Tab, Tabs, Typography } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import MinimizeIcon from '@mui/icons-material/Minimize'; import { useLocation, useNavigate } from 'react-router-dom';
import { useLocation } from 'react-router-dom'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useEffect, useState } from 'react';
import { Tooltip } from '@mui/material';
import { fetchCoins } from '../api';
declare global { declare global {
interface Window { interface Window {
@ -18,12 +21,86 @@ declare global {
// Определяем пропсы // Определяем пропсы
interface TopBarProps { interface TopBarProps {
onRegister?: () => void; // Опционально, если нужен обработчик регистрации onRegister?: () => void; // Опционально, если нужен обработчик регистрации
username?: string;
} }
export default function TopBar({ onRegister }: TopBarProps) { export default function TopBar({ onRegister, username }: TopBarProps) {
// Получаем текущий путь // Получаем текущий путь
const location = useLocation(); const location = useLocation();
const isLoginPage = location.pathname === '/login'; const isLoginPage = location.pathname === '/login';
const isLaunchPage = location.pathname.startsWith('/launch');
const isVersionsExplorerPage = location.pathname.startsWith('/');
const isRegistrationPage = location.pathname === '/registration';
const navigate = useNavigate();
const [coins, setCoins] = useState<number>(0);
const [value, setValue] = useState(0);
const [activePage, setActivePage] = useState('versions');
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
if (newValue === 0) {
navigate('/');
} else if (newValue === 1) {
navigate('/profile');
} else if (newValue === 2) {
navigate('/shop');
} else if (newValue === 3) {
navigate('/marketplace');
}
};
const handleLaunchPage = () => {
navigate('/');
};
const getPageTitle = () => {
if (isLoginPage) {
return 'Вход';
}
if (isLaunchPage) {
return 'Запуск';
}
if (isVersionsExplorerPage) {
if (activePage === 'versions') {
return 'Версии';
}
if (activePage === 'profile') {
return 'Профиль';
}
if (activePage === 'shop') {
return 'Магазин';
}
if (activePage === 'marketplace') {
return 'Рынок';
}
}
return 'Неизвестная страница';
};
// Функция для получения количества монет
const fetchCoinsData = async () => {
if (!username) return;
try {
const coinsData = await fetchCoins(username);
setCoins(coinsData.coins);
} catch (error) {
console.error('Ошибка при получении количества монет:', error);
}
};
useEffect(() => {
if (username) {
fetchCoinsData();
// Создаем интервалы для периодического обновления данных
const coinsInterval = setInterval(fetchCoinsData, 60000);
return () => {
clearInterval(coinsInterval);
};
}
}, [username]);
return ( return (
<Box <Box
@ -33,30 +110,216 @@ export default function TopBar({ onRegister }: TopBarProps) {
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
height: '50px', height: '7vh',
zIndex: 1000, zIndex: 1000,
width: '100%', width: '100%',
WebkitAppRegion: 'drag', WebkitAppRegion: 'drag',
overflow: 'hidden', overflow: 'hidden',
justifyContent: 'flex-end', // Всё содержимое справа justifyContent: 'space-between',
alignItems: 'center',
// marginLeft: '1em',
// marginRight: '1em',
}} }}
> >
{/* Правая часть со всеми кнопками */} {/* Левая часть */}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
WebkitAppRegion: 'no-drag', WebkitAppRegion: 'no-drag',
gap: '2vw', gap: '2vw',
padding: '1em',
alignItems: 'center', alignItems: 'center',
marginLeft: '1vw',
}}
>
{(isLaunchPage || isRegistrationPage) && (
<Button
variant="outlined"
onClick={() => handleLaunchPage()}
sx={{
width: '3em',
height: '3em',
borderRadius: '50%',
border: 'unset',
color: 'white',
minWidth: 'unset',
minHeight: 'unset',
}}
>
<ArrowBackRoundedIcon />
</Button>
)}
{!isLaunchPage && !isRegistrationPage && !isLoginPage && (
<Box
sx={{
borderBottom: 1,
borderColor: 'transparent',
'& .MuiTabs-indicator': {
backgroundColor: 'rgba(255, 77, 77, 1)',
},
}}
>
<Tabs
value={value}
onChange={handleChange}
aria-label="basic tabs example"
disableRipple={true}
>
<Tab
label="Версии"
disableRipple={true}
onClick={() => {
setActivePage('versions');
}}
sx={{
color: 'white',
fontFamily: 'Benzin-Bold',
fontSize: '0.7em',
'&.Mui-selected': {
color: 'rgba(255, 77, 77, 1)',
},
'&:hover': {
color: 'rgb(199, 199, 199)',
},
transition: 'all 0.3s ease',
}}
/>
<Tab
label="Профиль"
disableRipple={true}
onClick={() => {
setActivePage('profile');
}}
sx={{
color: 'white',
fontFamily: 'Benzin-Bold',
fontSize: '0.7em',
'&.Mui-selected': {
color: 'rgba(255, 77, 77, 1)',
},
'&:hover': {
color: 'rgb(199, 199, 199)',
},
transition: 'all 0.3s ease',
}}
/>
<Tab
label="Магазин"
disableRipple={true}
onClick={() => {
setActivePage('shop');
}}
sx={{
color: 'white',
fontFamily: 'Benzin-Bold',
fontSize: '0.7em',
'&.Mui-selected': {
color: 'rgba(255, 77, 77, 1)',
},
'&:hover': {
color: 'rgb(199, 199, 199)',
},
transition: 'all 0.3s ease',
}}
/>
<Tab
label="Рынок"
disableRipple={true}
onClick={() => {
setActivePage('marketplace');
}}
sx={{
color: 'white',
fontFamily: 'Benzin-Bold',
fontSize: '0.7em',
'&.Mui-selected': {
color: 'rgba(255, 77, 77, 1)',
},
'&:hover': {
color: 'rgb(199, 199, 199)',
},
transition: 'all 0.3s ease',
}}
/>
</Tabs>
</Box>
)}
</Box>
{/* Центр */}
<Box
sx={{
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexGrow: 1,
WebkitAppRegion: 'drag',
}}
>
<Typography
variant="h6"
sx={{ color: 'white', fontFamily: 'Benzin-Bold' }}
>
{getPageTitle()}
</Typography>
</Box>
{/* Правая часть со всеми кнопками */}
<Box
sx={{
display: 'flex',
WebkitAppRegion: 'no-drag',
gap: '1vw',
alignItems: 'center',
marginRight: '1vw',
}} }}
> >
{/* Кнопка регистрации, если на странице логина */} {/* Кнопка регистрации, если на странице логина */}
{username && (
<Tooltip
title="Попы — внутриигровая валюта, начисляемая за время игры на серверах."
arrow
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(0, 0, 0, 0.2)',
borderRadius: '16px',
padding: '6px 12px',
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
width: '24px',
height: '24px',
}}
>
<Typography sx={{ color: '#2bff00ff' }}>P</Typography>
</Box>
<Typography
variant="body1"
sx={{
color: 'white',
fontWeight: 'bold',
fontSize: '16px',
lineHeight: 1,
}}
>
{coins}
</Typography>
</Box>
</Tooltip>
)}
{isLoginPage && ( {isLoginPage && (
<Button <Button
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={() => onRegister && onRegister()} onClick={() => navigate('/registration')}
sx={{ sx={{
width: '10em', width: '10em',
height: '3em', height: '3em',
@ -87,7 +350,17 @@ export default function TopBar({ onRegister }: TopBarProps) {
borderRadius: '50%', borderRadius: '50%',
}} }}
> >
<MinimizeIcon sx={{ color: 'white' }} /> <svg
className="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium"
focusable="false"
aria-hidden="true"
viewBox="0 0 24 24"
>
<path
d="M 7 19 h 10 c 0.55 0 1 0.45 1 1 s -0.45 1 -1 1 H 7 c -0.55 0 -1 -0.45 -1 -1 s 0.45 -1 1 -1"
fill="white"
></path>
</svg>
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
@ -101,7 +374,7 @@ export default function TopBar({ onRegister }: TopBarProps) {
borderRadius: '50%', borderRadius: '50%',
}} }}
> >
<CloseIcon sx={{ color: 'white' }} /> <CloseRoundedIcon sx={{ color: 'white' }} />
</Button> </Button>
</Box> </Box>
</Box> </Box>

View File

@ -5,16 +5,14 @@ import {
Snackbar, Snackbar,
Alert, Alert,
LinearProgress, LinearProgress,
Modal,
} from '@mui/material'; } from '@mui/material';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import ServerStatus from '../components/ServerStatus/ServerStatus'; import ServerStatus from '../components/ServerStatus/ServerStatus';
import PopaPopa from '../components/popa-popa'; import PopaPopa from '../components/popa-popa';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import React from 'react'; import React from 'react';
import MemorySlider from '../components/Login/MemorySlider'; import SettingsModal from '../components/Settings/SettingsModal';
import FilesSelector from '../components/FilesSelector';
declare global { declare global {
interface Window { interface Window {
@ -30,7 +28,9 @@ declare global {
// Определяем тип для props // Определяем тип для props
interface LaunchPageProps { interface LaunchPageProps {
launchOptions: { onLaunchPage?: () => void;
launchOptions?: {
// Делаем опциональным
downloadUrl: string; downloadUrl: string;
apiReleaseUrl: string; apiReleaseUrl: string;
versionFileName: string; versionFileName: string;
@ -42,8 +42,14 @@ interface LaunchPageProps {
}; };
} }
const LaunchPage = ({ launchOptions }: LaunchPageProps) => { const LaunchPage = ({
onLaunchPage,
launchOptions = {} as any,
}: LaunchPageProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { versionId } = useParams();
const [versionConfig, setVersionConfig] = useState<any>(null);
// Начальное состояние должно быть пустым или с минимальными значениями // Начальное состояние должно быть пустым или с минимальными значениями
const [config, setConfig] = useState<{ const [config, setConfig] = useState<{
memory: number; memory: number;
@ -101,27 +107,82 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
}, [navigate]); }, [navigate]);
useEffect(() => { useEffect(() => {
// Загрузка конфигурации сборки при монтировании const fetchVersionConfig = async () => {
const loadPackConfig = async () => { if (!versionId) return;
try { try {
// Сначала проверяем, есть ли конфигурация в localStorage
const savedConfig = localStorage.getItem('selected_version_config');
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
setVersionConfig(parsedConfig);
// Устанавливаем значения памяти и preserveFiles из конфигурации
setConfig({
memory: parsedConfig.memory || 4096,
preserveFiles: parsedConfig.preserveFiles || [],
});
// Очищаем localStorage
localStorage.removeItem('selected_version_config');
return;
}
// Если нет в localStorage, запрашиваем с сервера
const result = await window.electron.ipcRenderer.invoke( const result = await window.electron.ipcRenderer.invoke(
'load-pack-config', 'get-version-config',
{ { versionId },
packName: launchOptions.packName,
},
); );
if (result.success && result.config) { if (result.success) {
// Полностью заменяем config значениями из файла setVersionConfig(result.config);
setConfig(result.config); setConfig({
memory: result.config.memory || 4096,
preserveFiles: result.config.preserveFiles || [],
});
} else {
// Если не удалось получить конфигурацию, используем значения по умолчанию
const defaultConfig = {
downloadUrl: '',
apiReleaseUrl: '',
versionFileName: `${versionId}_version.txt`,
packName: versionId || 'Comfort',
memory: 4096,
baseVersion: '1.21.4',
serverIp: 'popa-popa.ru',
fabricVersion: '0.16.14',
preserveFiles: ['popa-launcher-config.json'],
};
setVersionConfig(defaultConfig);
setConfig({
memory: defaultConfig.memory,
preserveFiles: defaultConfig.preserveFiles || [],
});
} }
} catch (error) { } catch (error) {
console.error('Ошибка при загрузке настроек:', error); console.error('Ошибка при получении настроек версии:', error);
// Используем значения по умолчанию
const defaultConfig = {
downloadUrl: '',
apiReleaseUrl: '',
versionFileName: `${versionId}_version.txt`,
packName: versionId || 'Comfort',
memory: 4096,
baseVersion: '1.21.4',
serverIp: 'popa-popa.ru',
fabricVersion: '0.16.14',
preserveFiles: ['popa-launcher-config.json'],
};
setVersionConfig(defaultConfig);
setConfig({
memory: defaultConfig.memory,
preserveFiles: defaultConfig.preserveFiles || [],
});
} }
}; };
loadPackConfig(); fetchVersionConfig();
}, [launchOptions.packName]); }, [versionId]);
const showNotification = ( const showNotification = (
message: string, message: string,
@ -134,94 +195,86 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
setNotification({ ...notification, open: false }); setNotification({ ...notification, open: false });
}; };
// Функция для запуска игры с настройками выбранной версии
const handleLaunchMinecraft = async () => { const handleLaunchMinecraft = async () => {
try { try {
setIsDownloading(true); setIsDownloading(true);
setDownloadProgress(0); setDownloadProgress(0);
setBuffer(10); setBuffer(10);
// Загружаем настройки сборки // Используем настройки выбранной версии или дефолтные
const result = await window.electron.ipcRenderer.invoke( const currentConfig = versionConfig || {
'load-pack-config', packName: versionId || 'Comfort',
{ memory: 4096,
packName: launchOptions.packName, baseVersion: '1.21.4',
}, serverIp: 'popa-popa.ru',
); fabricVersion: '0.16.14',
preserveFiles: [],
};
// Используйте уже существующий state вместо локальной переменной // Проверяем, является ли это ванильной версией
if (result.success && result.config) { const isVanillaVersion =
setConfig(result.config); // Обновляем state !currentConfig.downloadUrl || currentConfig.downloadUrl === '';
if (!isVanillaVersion) {
// Если это не ванильная версия, выполняем загрузку и распаковку
const packOptions = {
downloadUrl: currentConfig.downloadUrl,
apiReleaseUrl: currentConfig.apiReleaseUrl,
versionFileName: currentConfig.versionFileName,
packName: versionId || currentConfig.packName,
preserveFiles: config.preserveFiles,
};
// Передаем опции для скачивания
const downloadResult = await window.electron.ipcRenderer.invoke(
'download-and-extract',
packOptions,
);
if (downloadResult?.success) {
if (downloadResult.updated) {
showNotification(
`Сборка ${downloadResult.packName} успешно обновлена до версии ${downloadResult.version}`,
'success',
);
} else {
showNotification(
`Установлена актуальная версия сборки ${downloadResult.packName} (${downloadResult.version})`,
'info',
);
}
}
} else {
showNotification('Запускаем ванильный Minecraft...', 'info');
} }
// Опции для запуска Minecraft
const savedConfig = JSON.parse( const savedConfig = JSON.parse(
localStorage.getItem('launcher_config') || '{}', localStorage.getItem('launcher_config') || '{}',
); );
// Опции для скачивания сборки const options = {
const packOptions = { accessToken: savedConfig.accessToken,
downloadUrl: launchOptions.downloadUrl, uuid: savedConfig.uuid,
apiReleaseUrl: launchOptions.apiReleaseUrl, username: savedConfig.username,
versionFileName: launchOptions.versionFileName, memory: config.memory,
packName: launchOptions.packName, baseVersion: currentConfig.baseVersion,
preserveFiles: config.preserveFiles, packName: versionId || currentConfig.packName,
serverIp: currentConfig.serverIp,
fabricVersion: currentConfig.fabricVersion,
// Для ванильной версии устанавливаем флаг
isVanillaVersion: isVanillaVersion,
versionToLaunchOverride: isVanillaVersion ? versionId : undefined,
}; };
// Передаем опции для скачивания const launchResult = await window.electron.ipcRenderer.invoke(
const downloadResult = await window.electron.ipcRenderer.invoke( 'launch-minecraft',
'download-and-extract', options,
packOptions,
); );
if (downloadResult?.success) { if (launchResult?.success) {
let needsSecondAttempt = false; showNotification('Minecraft успешно запущен!', 'success');
if (downloadResult.updated) {
showNotification(
`Сборка ${downloadResult.packName} успешно обновлена до версии ${downloadResult.version}`,
'success',
);
needsSecondAttempt = true;
} else {
showNotification(
`Установлена актуальная версия сборки ${downloadResult.packName} (${downloadResult.version})`,
'info',
);
}
// Опции для запуска
const options = {
accessToken: savedConfig.accessToken,
uuid: savedConfig.uuid,
username: savedConfig.username,
memory: config.memory, // Используем state
baseVersion: launchOptions.baseVersion,
packName: launchOptions.packName,
serverIp: launchOptions.serverIp,
fabricVersion: launchOptions.fabricVersion,
};
const launchResult = await window.electron.ipcRenderer.invoke(
'launch-minecraft',
options,
);
if (needsSecondAttempt) {
showNotification(
'Завершаем настройку компонентов, повторный запуск...',
'info',
);
await new Promise((resolve) => setTimeout(resolve, 2000));
const secondAttempt = await window.electron.ipcRenderer.invoke(
'launch-minecraft',
options,
);
showNotification('Minecraft успешно запущен!', 'success');
} else if (launchResult?.success) {
showNotification('Minecraft успешно запущен!', 'success');
}
} }
} catch (error) { } catch (error) {
console.error('Error:', error); console.error('Error:', error);
@ -240,7 +293,7 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
}; };
await window.electron.ipcRenderer.invoke('save-pack-config', { await window.electron.ipcRenderer.invoke('save-pack-config', {
packName: launchOptions.packName, packName: versionId || versionConfig?.packName || 'Comfort',
config: configToSave, config: configToSave,
}); });
@ -267,7 +320,7 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
СЕРВЕР ГДЕ ВСЕМ НА ВАС ПОХУЙ СЕРВЕР ГДЕ ВСЕМ НА ВАС ПОХУЙ
</Typography> </Typography>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}> <Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
СЕРВЕР ГДЕ РАЗРЕШИНЫ ОДНОПОЛЫЕ БРАКИ СЕРВЕР ГДЕ РАЗРЕШЕНЫ ОДНОПОЛЫЕ БРАКИ
</Typography> </Typography>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}> <Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
СЕРВЕР ГДЕ ВСЕ ДОЛБАЕБЫ СЕРВЕР ГДЕ ВСЕ ДОЛБАЕБЫ
@ -285,7 +338,7 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
<Box> <Box>
<ServerStatus <ServerStatus
serverIp={launchOptions.serverIp} serverIp={versionConfig?.serverIp || 'popa-popa.ru'}
refreshInterval={30000} refreshInterval={30000}
/> />
</Box> </Box>
@ -365,65 +418,14 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
</Alert> </Alert>
</Snackbar> </Snackbar>
<Modal <SettingsModal
open={open} open={open}
onClose={handleClose} onClose={handleClose}
aria-labelledby="modal-modal-title" config={config}
aria-describedby="modal-modal-description" onConfigChange={setConfig}
> packName={versionId || versionConfig?.packName || 'Comfort'}
<Box onSave={savePackConfig}
sx={{ />
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
background:
'linear-gradient(-242.94deg, #000000 39.07%, #3b4187 184.73%)',
border: '2px solid #000',
boxShadow: 24,
p: 4,
borderRadius: '3vw',
gap: '1vh',
display: 'flex',
flexDirection: 'column',
}}
>
<Typography id="modal-modal-title" variant="body1" component="h2">
Файлы и папки, которые будут сохранены после переустановки сборки
</Typography>
<FilesSelector
packName={launchOptions.packName}
initialSelected={config.preserveFiles} // Передаем текущие выбранные файлы
onSelectionChange={(selected) => {
setConfig((prev) => ({ ...prev, preserveFiles: selected }));
}}
/>
<Typography variant="body1" sx={{ color: 'white' }}>
Оперативная память выделенная для Minecraft
</Typography>
<MemorySlider
memory={config.memory}
onChange={(e, value) => {
setConfig((prev) => ({ ...prev, memory: value as number }));
}}
/>
<Button
variant="contained"
color="success"
onClick={() => {
savePackConfig();
handleClose();
}}
sx={{
borderRadius: '3vw',
fontFamily: 'Benzin-Bold',
}}
>
Сохранить
</Button>
</Box>
</Modal>
</Box> </Box>
); );
}; };

View File

@ -38,13 +38,25 @@ const Login = () => {
console.log( console.log(
'Не удалось обновить токен, требуется новая авторизация', 'Не удалось обновить токен, требуется новая авторизация',
); );
const newSession = await authenticateWithElyBy( // Очищаем недействительные токены
config.username, saveConfig({
config.password, accessToken: '',
saveConfig, clientToken: '',
); });
if (!newSession) {
console.log('Авторизация не удалась'); // Пытаемся выполнить новую авторизацию
if (config.password) {
const newSession = await authenticateWithElyBy(
config.username,
config.password,
saveConfig,
);
if (!newSession) {
console.log('Авторизация не удалась');
return;
}
} else {
console.log('Требуется ввод пароля для новой авторизации');
return; return;
} }
} }
@ -53,6 +65,13 @@ const Login = () => {
} }
} else { } else {
console.log('Токен отсутствует, выполняем авторизацию...'); console.log('Токен отсутствует, выполняем авторизацию...');
// Проверяем наличие пароля
if (!config.password) {
console.log('Ошибка: не указан пароль');
alert('Введите пароль!');
return;
}
const session = await authenticateWithElyBy( const session = await authenticateWithElyBy(
config.username, config.username,
config.password, config.password,
@ -68,6 +87,11 @@ const Login = () => {
navigate('/'); navigate('/');
} catch (error) { } catch (error) {
console.log(`ОШИБКА при авторизации: ${error.message}`); console.log(`ОШИБКА при авторизации: ${error.message}`);
// Очищаем недействительные токены при ошибке
saveConfig({
accessToken: '',
clientToken: '',
});
} }
}; };
@ -79,12 +103,6 @@ const Login = () => {
handleInputChange={handleInputChange} handleInputChange={handleInputChange}
onLogin={authorization} onLogin={authorization}
/> />
<MemorySlider
memory={config.memory}
onChange={(e, value) => {
setConfig((prev) => ({ ...prev, memory: value as number }));
}}
/>
</Box> </Box>
); );
}; };

View File

@ -0,0 +1,398 @@
// src/renderer/pages/Marketplace.tsx
import { useEffect, useState } from 'react';
import {
Box,
Typography,
CircularProgress,
Button,
Grid,
Card,
CardContent,
CardMedia,
Pagination,
Tabs,
Tab,
Alert,
Snackbar,
} from '@mui/material';
import { isPlayerOnline, getPlayerServer } from '../utils/playerOnlineCheck';
import { buyItem, fetchMarketplace, MarketplaceResponse, Server } from '../api';
import PlayerInventory from '../components/PlayerInventory';
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`marketplace-tabpanel-${index}`}
aria-labelledby={`marketplace-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
}
export default function Marketplace() {
const [loading, setLoading] = useState<boolean>(true);
const [marketLoading, setMarketLoading] = useState<boolean>(false);
const [isOnline, setIsOnline] = useState<boolean>(false);
const [username, setUsername] = useState<string>('');
const [playerServer, setPlayerServer] = useState<Server | null>(null);
const [marketItems, setMarketItems] = useState<MarketplaceResponse | null>(
null,
);
const [page, setPage] = useState<number>(1);
const [totalPages, setTotalPages] = useState<number>(1);
const [tabValue, setTabValue] = useState<number>(0);
const [notification, setNotification] = useState<{
open: boolean;
message: string;
type: 'success' | 'error';
}>({
open: false,
message: '',
type: 'success',
});
// Функция для проверки онлайн-статуса игрока и определения сервера
const checkPlayerStatus = async () => {
if (!username) return;
try {
setLoading(true);
// Проверяем, онлайн ли игрок и получаем сервер, где он находится
const { online, server } = await getPlayerServer(username);
setIsOnline(online);
setPlayerServer(server);
// Если игрок онлайн и на каком-то сервере, загружаем предметы рынка
if (online && server) {
await loadMarketItems(server.ip, 1);
}
} catch (error) {
console.error('Ошибка при проверке онлайн-статуса:', error);
setIsOnline(false);
setPlayerServer(null);
} finally {
setLoading(false);
}
};
// Функция для загрузки предметов маркетплейса
const loadMarketItems = async (serverIp: string, pageNumber: number) => {
try {
setMarketLoading(true);
const marketData = await fetchMarketplace(serverIp, pageNumber, 10); // 10 предметов на страницу
setMarketItems(marketData);
setPage(marketData.page);
setTotalPages(marketData.pages);
} catch (error) {
console.error('Ошибка при загрузке предметов рынка:', error);
setMarketItems(null);
} finally {
setMarketLoading(false);
}
};
// Обработчик смены страницы
const handlePageChange = (
_event: React.ChangeEvent<unknown>,
newPage: number,
) => {
if (playerServer) {
loadMarketItems(playerServer.ip, newPage);
}
};
// Обработчик смены вкладок
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
// Обновляем функцию handleBuyItem в Marketplace.tsx
const handleBuyItem = async (itemId: string) => {
try {
if (username) {
const result = await buyItem(username, itemId);
setNotification({
open: true,
message:
result.message ||
'Предмет успешно куплен! Он будет добавлен в ваш инвентарь.',
type: 'success',
});
// Обновляем список предметов
if (playerServer) {
loadMarketItems(playerServer.ip, page);
}
}
} catch (error) {
console.error('Ошибка при покупке предмета:', error);
setNotification({
open: true,
message:
error instanceof Error
? error.message
: 'Ошибка при покупке предмета',
type: 'error',
});
}
};
// Закрытие уведомления
const handleCloseNotification = () => {
setNotification({ ...notification, open: false });
};
// Получаем имя пользователя из localStorage при монтировании компонента
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
const config = JSON.parse(savedConfig);
if (config.username) {
setUsername(config.username);
}
}
}, []);
// Проверяем статус при изменении username
useEffect(() => {
if (username) {
checkPlayerStatus();
}
}, [username]);
// Показываем loader во время проверки
if (loading) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
gap: 2,
}}
>
<CircularProgress size={60} />
<Typography variant="h6" color="white">
Проверяем, находитесь ли вы на сервере...
</Typography>
</Box>
);
}
// Если игрок не онлайн
if (!isOnline) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
gap: 3,
padding: 4,
textAlign: 'center',
}}
>
<Typography variant="h4" color="error">
Доступ к рынку ограничен
</Typography>
<Typography variant="h6" color="white">
Для доступа к рынку вам необходимо находиться на одном из серверов
игры.
</Typography>
<Typography variant="body1" color="white" sx={{ opacity: 0.8 }}>
Зайдите на любой сервер и обновите страницу.
</Typography>
<Button
variant="contained"
onClick={checkPlayerStatus}
sx={{
mt: '1%',
borderRadius: '20px',
p: '10px 25px',
color: 'white',
backgroundColor: 'rgb(255, 77, 77)',
'&:hover': {
backgroundColor: 'rgba(255, 77, 77, 0.5)',
},
fontFamily: 'Benzin-Bold',
}}
>
Проверить снова
</Button>
</Box>
);
}
return (
<Box sx={{ padding: 3 }}>
<Typography variant="h4" color="white" gutterBottom>
Рынок сервера {playerServer?.name || ''}
</Typography>
{/* Вкладки */}
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={tabValue}
onChange={handleTabChange}
aria-label="marketplace tabs"
>
<Tab label="Товары" />
<Tab label="Мой инвентарь" />
</Tabs>
</Box>
{/* Содержимое вкладки "Товары" */}
<TabPanel value={tabValue} index={0}>
{marketLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress />
</Box>
) : !marketItems || marketItems.items.length === 0 ? (
<Box sx={{ mt: 4, textAlign: 'center' }}>
<Typography variant="h6" color="white">
На данный момент на рынке нет предметов.
</Typography>
<Button
variant="contained"
color="primary"
onClick={() =>
playerServer && loadMarketItems(playerServer.ip, 1)
}
sx={{ mt: 2 }}
>
Обновить
</Button>
</Box>
) : (
<>
<Grid container spacing={2} sx={{ mt: 2 }}>
{marketItems.items.map((item) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={item.id}>
<Card sx={{ bgcolor: 'rgba(255, 255, 255, 0.05)' }}>
<CardMedia
component="img"
sx={{
height: 140,
objectFit: 'contain',
bgcolor: 'rgba(0, 0, 0, 0.2)',
p: 1,
imageRendering: 'pixelated',
}}
image={`/minecraft/${item.material.toLowerCase()}.png`}
alt={item.material}
/>
<CardContent>
<Typography variant="h6" color="white">
{item.display_name ||
item.material
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (l) => l.toUpperCase())}
</Typography>
<Typography variant="body2" color="white">
Количество: {item.amount}
</Typography>
<Typography variant="body2" color="white">
Цена: {item.price} монет
</Typography>
<Typography
variant="body2"
color="white"
sx={{ opacity: 0.7 }}
>
Продавец: {item.seller_name}
</Typography>
<Button
variant="contained"
color="primary"
fullWidth
sx={{ mt: 2 }}
onClick={() => handleBuyItem(item.id)}
>
Купить
</Button>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
/>
</Box>
)}
</>
)}
</TabPanel>
{/* Содержимое вкладки "Мой инвентарь" */}
<TabPanel value={tabValue} index={1}>
{playerServer && username ? (
<PlayerInventory
username={username}
serverIp={playerServer.ip}
onSellSuccess={() => {
// После успешной продажи, обновляем список товаров
if (playerServer) {
loadMarketItems(playerServer.ip, 1);
}
// Показываем уведомление
setNotification({
open: true,
message: 'Предмет успешно выставлен на продажу!',
type: 'success',
});
}}
/>
) : (
<Typography
variant="body1"
color="white"
sx={{ textAlign: 'center', my: 4 }}
>
Не удалось загрузить инвентарь.
</Typography>
)}
</TabPanel>
{/* Уведомления */}
<Snackbar
open={notification.open}
autoHideDuration={6000}
onClose={handleCloseNotification}
>
<Alert
onClose={handleCloseNotification}
severity={notification.type}
sx={{ width: '100%' }}
>
{notification.message}
</Alert>
</Snackbar>
</Box>
);
}

View File

@ -0,0 +1,373 @@
import { useEffect, useRef, useState } from 'react';
import SkinViewer from '../components/SkinViewer';
import {
fetchPlayer,
uploadSkin,
fetchCapes,
Cape,
activateCape,
deactivateCape,
} from '../api';
import {
Box,
Typography,
Paper,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
Alert,
CircularProgress,
} from '@mui/material';
import CapeCard from '../components/CapeCard';
export default function Profile() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [walkingSpeed, setWalkingSpeed] = useState<number>(0.5);
const [skin, setSkin] = useState<string>('');
const [cape, setCape] = useState<string>('');
const [username, setUsername] = useState<string>('');
const [skinFile, setSkinFile] = useState<File | null>(null);
const [skinModel, setSkinModel] = useState<string>(''); // slim или classic
const [uploadStatus, setUploadStatus] = useState<
'idle' | 'loading' | 'success' | 'error'
>('idle');
const [statusMessage, setStatusMessage] = useState<string>('');
const [isDragOver, setIsDragOver] = useState<boolean>(false);
const [capes, setCapes] = useState<Cape[]>([]);
const [uuid, setUuid] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
const config = JSON.parse(savedConfig);
if (config.uuid) {
loadPlayerData(config.uuid);
setUsername(config.username || '');
loadCapesData(config.username || '');
setUuid(config.uuid || '');
}
}
}, []);
const loadPlayerData = async (uuid: string) => {
try {
setLoading(true);
const player = await fetchPlayer(uuid);
setSkin(player.skin_url);
setCape(player.cloak_url);
setLoading(false);
} catch (error) {
console.error('Ошибка при получении данных игрока:', error);
setSkin('');
setCape('');
}
};
const loadCapesData = async (username: string) => {
try {
setLoading(true);
const capesData = await fetchCapes(username);
setCapes(capesData);
setLoading(false);
} catch (error) {
console.error('Ошибка при получении плащей:', error);
setCapes([]);
}
};
// Обработка перетаскивания файла
const handleFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0];
if (file.type === 'image/png') {
setSkinFile(file);
setStatusMessage(`Файл "${file.name}" готов к загрузке`);
} else {
setStatusMessage('Пожалуйста, выберите файл в формате PNG');
setUploadStatus('error');
}
}
};
// Обработка выбора файла
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
if (file.type === 'image/png') {
setSkinFile(file);
setStatusMessage(`Файл "${file.name}" готов к загрузке`);
} else {
setStatusMessage('Пожалуйста, выберите файл в формате PNG');
setUploadStatus('error');
}
}
};
const handleActivateCape = async (cape_id: string) => {
setLoading(true);
await activateCape(username, cape_id);
await loadCapesData(username);
setLoading(false);
};
const handleDeactivateCape = async (cape_id: string) => {
setLoading(true);
await deactivateCape(username, cape_id);
await loadCapesData(username);
await loadPlayerData(uuid);
setLoading(false);
};
// Отправка запроса на установку скина
const handleUploadSkin = async () => {
setLoading(true);
if (!skinFile || !username) {
setStatusMessage('Необходимо выбрать файл и указать имя пользователя');
setUploadStatus('error');
return;
}
setUploadStatus('loading');
try {
await uploadSkin(username, skinFile, skinModel);
setStatusMessage('Скин успешно загружен!');
setUploadStatus('success');
// Обновляем информацию о игроке, чтобы увидеть новый скин
const config = JSON.parse(
localStorage.getItem('launcher_config') || '{}',
);
if (config.uuid) {
loadPlayerData(config.uuid);
}
} catch (error) {
setStatusMessage(
`Ошибка: ${error instanceof Error ? error.message : 'Не удалось загрузить скин'}`,
);
setUploadStatus('error');
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
my: 4,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '100px',
width: '100%',
justifyContent: 'center',
}}
>
{loading ? (
<CircularProgress />
) : (
<>
<Paper
elevation={0}
sx={{
p: 0,
borderRadius: 2,
overflow: 'hidden',
mb: 4,
bgcolor: 'transparent',
}}
>
{/* Используем переработанный компонент SkinViewer */}
<Typography
sx={{
fontFamily: 'Benzin-Bold',
alignSelf: 'center',
justifySelf: 'center',
textAlign: 'center',
width: '100%',
mb: '5vw',
fontSize: '3vw',
color: 'white',
}}
>
{username}
</Typography>
<SkinViewer
width={300}
height={400}
skinUrl={skin}
capeUrl={cape}
walkingSpeed={walkingSpeed}
autoRotate={true}
/>
</Paper>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<Box
sx={{
width: '100%',
maxWidth: '500px',
bgcolor: 'rgba(255, 255, 255, 0.05)',
padding: '3vw',
borderRadius: '1vw',
}}
>
<Box
sx={{
border: '2px dashed',
borderColor: isDragOver ? 'primary.main' : 'grey.400',
borderRadius: 2,
p: 3,
mb: 2,
textAlign: 'center',
cursor: 'pointer',
bgcolor: isDragOver
? 'rgba(25, 118, 210, 0.08)'
: 'transparent',
transition: 'all 0.2s',
}}
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleFileDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
type="file"
ref={fileInputRef}
accept=".png"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Typography sx={{ color: 'white' }}>
{skinFile
? `Выбран файл: ${skinFile.name}`
: 'Перетащите PNG файл скина или кликните для выбора'}
</Typography>
</Box>
<FormControl
color="primary"
fullWidth
sx={{ mb: 2, color: 'white' }}
>
<InputLabel sx={{ color: 'white' }}>Модель скина</InputLabel>
<Select
value={skinModel}
label="Модель скина"
onChange={(e) => setSkinModel(e.target.value)}
sx={{
color: 'white',
borderColor: 'white',
'& .MuiInputBase-input': {
border: '1px solid white',
transition: 'unset',
},
'&:focus': {
borderRadius: 4,
borderColor: '#80bdff',
boxShadow: '0 0 0 0.2rem rgba(0,123,255,.25)',
},
}}
>
<MenuItem value="">По умолчанию</MenuItem>
<MenuItem value="slim">Тонкая (Alex)</MenuItem>
<MenuItem value="classic">Классическая (Steve)</MenuItem>
</Select>
</FormControl>
{uploadStatus === 'error' && (
<Alert severity="error" sx={{ mb: 2 }}>
{statusMessage}
</Alert>
)}
{uploadStatus === 'success' && (
<Alert severity="success" sx={{ mb: 2 }}>
{statusMessage}
</Alert>
)}
<Button
sx={{
color: 'white',
borderRadius: '20px',
p: '10px 25px',
backgroundColor: 'rgb(0, 134, 0)',
'&:hover': {
backgroundColor: 'rgba(0, 134, 0, 0.5)',
},
fontFamily: 'Benzin-Bold',
}}
variant="contained"
fullWidth
onClick={handleUploadSkin}
disabled={uploadStatus === 'loading' || !skinFile}
startIcon={
uploadStatus === 'loading' ? (
<CircularProgress size={20} color="inherit" />
) : null
}
>
{uploadStatus === 'loading' ? (
<Typography sx={{ color: 'white' }}>Загрузка...</Typography>
) : (
<Typography sx={{ color: 'white' }}>
Установить скин
</Typography>
)}
</Button>
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
gap: 2,
}}
>
<Typography>Ваши плащи</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 2,
flexWrap: 'wrap',
}}
>
{capes.map((cape) => (
<CapeCard
key={cape.cape_id}
cape={cape}
mode="profile"
onAction={
cape.is_active ? handleDeactivateCape : handleActivateCape
}
actionDisabled={loading}
/>
))}
</Box>
</Box>
</Box>
</>
)}
</Box>
);
}

View File

@ -0,0 +1,376 @@
import Stepper from '@mui/material/Stepper';
import Step from '@mui/material/Step';
import StepLabel from '@mui/material/StepLabel';
import { useEffect, useRef, useState } from 'react';
import {
StepConnector,
stepConnectorClasses,
StepIconProps,
styled,
Typography,
Box,
TextField,
Button,
Snackbar,
CircularProgress,
} from '@mui/material';
import LoginRoundedIcon from '@mui/icons-material/LoginRounded';
import VerifiedRoundedIcon from '@mui/icons-material/VerifiedRounded';
import AssignmentIndRoundedIcon from '@mui/icons-material/AssignmentIndRounded';
import {
generateVerificationCode,
registerUser,
getVerificationStatus,
} from '../api';
import QRCodeStyling from 'qr-code-styling';
import popalogo from '../../../assets/icons/popa-popa.svg';
const ColorlibConnector = styled(StepConnector)(({ theme }) => ({
[`&.${stepConnectorClasses.alternativeLabel}`]: {
top: 22,
},
[`&.${stepConnectorClasses.active}`]: {
[`& .${stepConnectorClasses.line}`]: {
backgroundImage:
'linear-gradient( 95deg,rgb(242,113,33) 0%,rgb(233,64,87) 50%,rgb(138,35,135) 100%)',
},
},
[`&.${stepConnectorClasses.completed}`]: {
[`& .${stepConnectorClasses.line}`]: {
backgroundImage:
'linear-gradient( 95deg,rgb(242,113,33) 0%,rgb(233,64,87) 50%,rgb(138,35,135) 100%)',
},
},
[`& .${stepConnectorClasses.line}`]: {
height: 3,
border: 0,
backgroundColor: '#eaeaf0',
borderRadius: 1,
...theme.applyStyles('dark', {
backgroundColor: theme.palette.grey[800],
}),
},
}));
const ColorlibStepIconRoot = styled('div')<{
ownerState: { completed?: boolean; active?: boolean };
}>(({ theme }) => ({
backgroundColor: '#ccc',
zIndex: 1,
color: '#fff',
width: 50,
height: 50,
display: 'flex',
borderRadius: '50%',
justifyContent: 'center',
alignItems: 'center',
...theme.applyStyles('dark', {
backgroundColor: theme.palette.grey[700],
}),
variants: [
{
props: ({ ownerState }) => ownerState.active,
style: {
backgroundImage:
'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
boxShadow: '0 4px 10px 0 rgba(0,0,0,.25)',
},
},
{
props: ({ ownerState }) => ownerState.completed,
style: {
backgroundImage:
'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
},
},
],
}));
function ColorlibStepIcon(props: StepIconProps) {
const { active, completed, className } = props;
const icons: { [index: string]: React.ReactElement<unknown> } = {
1: <AssignmentIndRoundedIcon />,
2: <VerifiedRoundedIcon />,
3: <LoginRoundedIcon />,
};
return (
<ColorlibStepIconRoot
ownerState={{ completed, active }}
className={className}
>
{icons[String(props.icon)]}
</ColorlibStepIconRoot>
);
}
const qrCode = new QRCodeStyling({
width: 300,
height: 300,
// image: popalogo,
shape: 'square',
margin: 10,
dotsOptions: {
gradient: {
type: 'linear',
colorStops: [
{
offset: 0,
color: 'rgb(242,113,33)',
},
{
offset: 1,
color: 'rgb(233,64,87)',
},
],
},
type: 'rounded',
},
imageOptions: {
crossOrigin: 'anonymous',
margin: 20,
imageSize: 0.5,
},
backgroundOptions: {
color: 'transparent',
},
});
export const Registration = () => {
const [activeStep, setActiveStep] = useState(0);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [open, setOpen] = useState(false);
const [message, setMessage] = useState('');
const [verificationCode, setVerificationCode] = useState<string | null>(null);
const ref = useRef(null);
const [url, setUrl] = useState('');
const steps = ['Создание аккаунта', 'Верификация аккаунта в телеграмме'];
useEffect(() => {
if (ref.current) {
qrCode.append(ref.current);
}
}, []);
useEffect(() => {
qrCode.update({
data: url,
});
}, [url]);
const handleCreateAccount = async () => {
const response = await registerUser(username, password);
if (response.status === 'success') {
setActiveStep(1);
} else {
setOpen(true);
setMessage(response.status);
}
};
const handleClose = () => {
setOpen(false);
};
useEffect(() => {
if (activeStep === 1) {
handleGenerateVerificationCode(username);
setUrl(`https://t.me/popa_popa_popa_bot?start=${username}`);
const intervalId = setInterval(() => {
handleVerifyCode();
}, 5000);
return () => {
clearInterval(intervalId);
};
}
}, [activeStep]);
const handleGenerateVerificationCode = async (username: string) => {
console.log(username);
const response = await generateVerificationCode(username);
setVerificationCode(response.code);
};
const handleVerifyCode = async () => {
const response = await getVerificationStatus(username);
if (response.is_verified) {
window.location.href = '/login';
}
};
const handleOpenBot = () => {
window.open(`https://t.me/popa_popa_popa_bot?start=${username}`, '_blank');
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Stepper
activeStep={activeStep}
alternativeLabel
connector={<ColorlibConnector />}
>
{steps.map((label) => (
<Step key={label}>
<StepLabel
sx={{
'& .MuiStepLabel-label': {
color: 'white',
},
'& .Mui-completed': {
color: 'white !important',
},
'& .Mui-active': {
backgroundImage:
'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
},
}}
StepIconComponent={ColorlibStepIcon}
>
{label}
</StepLabel>
</Step>
))}
</Stepper>
{activeStep === 0 && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
alignItems: 'center',
}}
>
<Typography variant="h6">Создание аккаунта</Typography>
<Typography variant="body1">Введите ваш никнейм</Typography>
<TextField
required
name="username"
variant="outlined"
value={username}
onChange={(e) => setUsername(e.target.value)}
sx={{
width: '100%',
// '& .MuiFormLabel-root': {
// color: 'white',
// },
'& .MuiInputBase-input': {
color: 'white',
},
'& .MuiInput-underline:after': {
borderBottomColor: '#B2BAC2',
},
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: '#E0E3E7',
color: 'white',
},
'&:hover fieldset': {
borderColor: '#B2BAC2',
},
'&.Mui-focused fieldset': {
borderColor: '#6F7E8C',
},
},
}}
/>
<Typography variant="body1">Введите ваш пароль</Typography>
<TextField
required
name="password"
type="password"
variant="outlined"
value={password}
onChange={(e) => setPassword(e.target.value)}
sx={{
width: '100%',
// '& .MuiFormLabel-root': {
// color: 'white',
// },
'& .MuiInputBase-input': {
color: 'white',
},
'& .MuiInput-underline:after': {
borderBottomColor: '#B2BAC2',
},
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: '#E0E3E7',
color: 'white',
},
'&:hover fieldset': {
borderColor: '#B2BAC2',
},
'&.Mui-focused fieldset': {
borderColor: '#6F7E8C',
},
},
}}
/>
<Button
variant="contained"
color="primary"
sx={{ width: '100%', mt: 2 }}
onClick={handleCreateAccount}
>
Создать
</Button>
</Box>
)}
{activeStep === 1 && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
alignItems: 'center',
}}
>
<Typography variant="h6">Откройте бота в телеграмме</Typography>
<Button
variant="contained"
color="primary"
sx={{ width: '100%', mt: 2 }}
onClick={handleOpenBot}
>
Открыть бота
</Button>
<div ref={ref} />
<Typography variant="body1">
Введите код верификации в боте
</Typography>
{verificationCode ? (
<>
<Typography
variant="h2"
sx={{
backgroundImage:
'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{verificationCode}
</Typography>
<Typography variant="body1">Ждем ответа от бота</Typography>
<CircularProgress />
</>
) : (
<CircularProgress />
)}
</Box>
)}
<Snackbar
open={open}
autoHideDuration={6000}
onClose={handleClose}
message={message}
/>
</Box>
);
};

130
src/renderer/pages/Shop.tsx Normal file
View File

@ -0,0 +1,130 @@
import { Box } from '@mui/material';
import { Typography } from '@mui/material';
import CapeCard from '../components/CapeCard';
import {
Cape,
fetchCapes,
fetchCapesStore,
purchaseCape,
StoreCape,
} from '../api';
import { useEffect, useState } from 'react';
export default function Shop() {
const [storeCapes, setStoreCapes] = useState<StoreCape[]>([]);
const [userCapes, setUserCapes] = useState<Cape[]>([]);
const [username, setUsername] = useState<string>('');
const [uuid, setUuid] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
// Функция для загрузки плащей из магазина
const loadStoreCapes = async () => {
try {
const capes = await fetchCapesStore();
setStoreCapes(capes);
} catch (error) {
console.error('Ошибка при получении плащей магазина:', error);
setStoreCapes([]);
}
};
// Функция для загрузки плащей пользователя
const loadUserCapes = async (username: string) => {
try {
const userCapes = await fetchCapes(username);
setUserCapes(userCapes);
} catch (error) {
console.error('Ошибка при получении плащей пользователя:', error);
setUserCapes([]);
}
};
const handlePurchaseCape = async (cape_id: string) => {
try {
await purchaseCape(username, cape_id);
await loadUserCapes(username);
} catch (error) {
console.error('Ошибка при покупке плаща:', error);
}
};
// Загружаем данные при монтировании
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
const config = JSON.parse(savedConfig);
if (config.uuid && config.username) {
setUsername(config.username);
setUuid(config.uuid);
setLoading(true);
// Загружаем оба списка плащей
Promise.all([loadStoreCapes(), loadUserCapes(config.username)]).finally(
() => {
setLoading(false);
},
);
}
}
}, []);
// Фильтруем плащи, которые уже куплены пользователем
const availableCapes = storeCapes.filter(
(storeCape) =>
!userCapes.some((userCape) => userCape.cape_id === storeCape.id),
);
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '2vw',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
}}
>
{loading ? (
<Typography>Загрузка...</Typography>
) : (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flexWrap: 'wrap',
alignContent: 'flex-start',
width: '90%',
height: '80%',
gap: '2vw',
}}
>
<Typography variant="h6">Доступные плащи</Typography>
{availableCapes.length > 0 ? (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: '2vw',
flexWrap: 'wrap',
}}
>
{availableCapes.map((cape) => (
<CapeCard
key={cape.id}
cape={cape}
mode="shop"
onAction={handlePurchaseCape}
/>
))}
</Box>
) : (
<Typography>У вас уже есть все доступные плащи!</Typography>
)}
</Box>
)}
</Box>
);
}

View File

@ -0,0 +1,394 @@
import { useEffect, useState } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardMedia,
CardContent,
CardActions,
Button,
CircularProgress,
Modal,
List,
ListItem,
ListItemText,
IconButton,
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import AddIcon from '@mui/icons-material/Add';
import DownloadIcon from '@mui/icons-material/Download';
interface VersionCardProps {
id: string;
name: string;
imageUrl: string;
version: string;
onSelect: (id: string) => void;
}
const VersionCard: React.FC<VersionCardProps> = ({
id,
name,
imageUrl,
version,
onSelect,
}) => {
return (
<Card
sx={{
backgroundColor: 'rgba(30, 30, 50, 0.8)',
backdropFilter: 'blur(10px)',
width: '35vw',
height: '35vh',
minWidth: 'unset',
minHeight: 'unset',
display: 'flex',
flexDirection: 'column',
borderRadius: '16px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
transition: 'transform 0.3s, box-shadow 0.3s',
overflow: 'hidden',
cursor: 'pointer',
}}
onClick={() => onSelect(id)}
>
<CardContent
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '10%',
}}
>
<Typography
gutterBottom
variant="h5"
component="div"
sx={{
fontWeight: 'bold',
color: '#ffffff',
fontSize: '1.5rem',
}}
>
{name}
</Typography>
</CardContent>
</Card>
);
};
interface VersionInfo {
id: string;
name: string;
version: string;
imageUrl?: string;
config?: {
downloadUrl: string;
apiReleaseUrl: string;
versionFileName: string;
packName: string;
memory: number;
baseVersion: string;
serverIp: string;
fabricVersion: string;
preserveFiles: string[];
};
}
interface AvailableVersionInfo {
id: string;
name: string;
version: string;
imageUrl?: string;
config: {
downloadUrl: string;
apiReleaseUrl: string;
versionFileName: string;
packName: string;
memory: number;
baseVersion: string;
serverIp: string;
fabricVersion: string;
preserveFiles: string[];
};
}
// В компоненте VersionsExplorer
export const VersionsExplorer = () => {
const [installedVersions, setInstalledVersions] = useState<VersionInfo[]>([]);
const [availableVersions, setAvailableVersions] = useState<
AvailableVersionInfo[]
>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [downloadLoading, setDownloadLoading] = useState<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
const fetchVersions = async () => {
try {
setLoading(true);
// Получаем список установленных версий через IPC
const installedResult = await window.electron.ipcRenderer.invoke(
'get-installed-versions',
);
if (installedResult.success) {
setInstalledVersions(installedResult.versions);
}
// Получаем доступные версии с GitHub Gist
const availableResult = await window.electron.ipcRenderer.invoke(
'get-available-versions',
{
gistUrl:
'https://gist.githubusercontent.com/DIKER0K/06cd12fb3a4d08b1f0f8c763a7d05e06/raw/versions.json',
},
);
if (availableResult.success) {
setAvailableVersions(availableResult.versions);
}
} catch (error) {
console.error('Ошибка при загрузке версий:', error);
// Можно добавить обработку ошибки, например показать уведомление
} finally {
setLoading(false);
}
};
fetchVersions();
}, []);
const handleSelectVersion = (version: VersionInfo) => {
localStorage.setItem(
'selected_version_config',
JSON.stringify(version.config || {}),
);
navigate(`/launch/${version.id}`);
};
const handleAddVersion = () => {
setModalOpen(true);
};
const handleCloseModal = () => {
setModalOpen(false);
};
const handleDownloadVersion = async (version: AvailableVersionInfo) => {
try {
setDownloadLoading(version.id);
// Скачивание и установка выбранной версии
const downloadResult = await window.electron.ipcRenderer.invoke(
'download-and-extract',
{
downloadUrl: version.config.downloadUrl,
apiReleaseUrl: version.config.apiReleaseUrl,
versionFileName: version.config.versionFileName,
packName: version.id,
preserveFiles: version.config.preserveFiles || [],
},
);
if (downloadResult?.success) {
// Добавляем скачанную версию в список установленных
setInstalledVersions((prev) => [...prev, version]);
setModalOpen(false);
}
} catch (error) {
console.error(`Ошибка при скачивании версии ${version.id}:`, error);
} finally {
setDownloadLoading(null);
}
};
// Карточка добавления новой версии
const AddVersionCard = () => (
<Card
sx={{
backgroundColor: 'rgba(30, 30, 50, 0.8)',
backdropFilter: 'blur(10px)',
width: '35vw',
height: '35vh',
minWidth: 'unset',
minHeight: 'unset',
display: 'flex',
flexDirection: 'column',
borderRadius: '16px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
transition: 'transform 0.3s, box-shadow 0.3s',
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
}}
onClick={handleAddVersion}
>
<AddIcon sx={{ fontSize: 60, color: '#fff' }} />
<Typography
variant="h6"
sx={{
color: '#fff',
}}
>
Добавить
</Typography>
<Typography
variant="h6"
sx={{
color: '#fff',
}}
>
версию
</Typography>
</Card>
);
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
paddingLeft: '5vw',
paddingRight: '5vw',
}}
>
{loading ? (
<Box display="flex" justifyContent="center" my={5}>
<CircularProgress />
</Box>
) : (
<Grid
container
spacing={3}
sx={{
width: '100%',
overflowY: 'auto',
justifyContent: 'center',
}}
>
{/* Показываем установленные версии или дефолтную, если она есть */}
{installedVersions.length > 0 ? (
installedVersions.map((version) => (
<Grid
key={version.id}
size={{ xs: 'auto', sm: 'auto', md: 'auto' }}
>
<VersionCard
id={version.id}
name={version.name}
imageUrl={
version.imageUrl ||
'https://via.placeholder.com/300x140?text=Minecraft'
}
version={version.version}
onSelect={() => handleSelectVersion(version)}
/>
</Grid>
))
) : (
// Если нет ни одной версии, показываем карточку добавления
<Grid size={{ xs: 'auto', sm: 'auto', md: 'auto' }}>
<AddVersionCard />
</Grid>
)}
{/* Всегда добавляем карточку для добавления новых версий */}
{installedVersions.length > 0 && (
<Grid size={{ xs: 'auto', sm: 'auto', md: 'auto' }}>
<AddVersionCard />
</Grid>
)}
</Grid>
)}
{/* Модальное окно для выбора версии для скачивания */}
<Modal
open={modalOpen}
onClose={handleCloseModal}
aria-labelledby="modal-versions"
aria-describedby="modal-available-versions"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
maxHeight: '80vh',
overflowY: 'auto',
background: 'linear-gradient(45deg, #000000 10%, #3b4187 184.73%)',
border: '2px solid #000',
boxShadow: 24,
p: 4,
borderRadius: '3vw',
gap: '1vh',
display: 'flex',
flexDirection: 'column',
backdropFilter: 'blur(10px)',
}}
>
<Typography variant="h6" component="h2" sx={{ color: '#fff' }}>
Доступные версии для скачивания
</Typography>
{availableVersions.length === 0 ? (
<Typography sx={{ color: '#fff', mt: 2 }}>
Загрузка доступных версий...
</Typography>
) : (
<List sx={{ mt: 2 }}>
{availableVersions.map((version) => (
<ListItem
key={version.id}
sx={{
borderRadius: '8px',
mb: 1,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
}}
onClick={() => handleSelectVersion(version)}
>
<ListItemText
primary={version.name}
secondary={version.version}
primaryTypographyProps={{ color: '#fff' }}
secondaryTypographyProps={{
color: 'rgba(255,255,255,0.7)',
}}
/>
</ListItem>
))}
</List>
)}
<Button
onClick={handleCloseModal}
variant="outlined"
sx={{
mt: 3,
alignSelf: 'center',
borderColor: '#fff',
color: '#fff',
'&:hover': {
borderColor: '#ccc',
backgroundColor: 'rgba(255,255,255,0.1)',
},
}}
>
Закрыть
</Button>
</Box>
</Modal>
</Box>
);
};

View File

@ -0,0 +1,128 @@
import { fetchActiveServers, fetchOnlinePlayers, Server } from '../api';
/**
* Проверяет, находится ли указанный игрок онлайн на любом из серверов
* @param username Имя игрока для проверки
* @returns {Promise<boolean>} true, если игрок онлайн хотя бы на одном сервере
*/
export async function isPlayerOnline(username: string): Promise<boolean> {
try {
console.log('Начинаем проверку статуса для:', username);
// Получаем список активных серверов (теперь это массив)
const servers = await fetchActiveServers();
console.log('Ответ API активных серверов:', servers);
// Фильтруем серверы с игроками
const serversWithPlayers = servers.filter(
(server) => server.online_players > 0,
);
// Если нет серверов с игроками, игрок точно не онлайн
if (serversWithPlayers.length === 0) {
return false;
}
// Проверяем каждый сервер на наличие игрока
const checkPromises = serversWithPlayers.map(async (server) => {
try {
const onlinePlayers = await fetchOnlinePlayers(server.id);
// Проверяем, есть ли игрок с указанным именем в списке
// Предполагая, что онлайн игроки хранятся в массиве online_players
return (
Array.isArray(onlinePlayers.online_players) &&
onlinePlayers.online_players.some(
(player) => player.username === username,
)
);
} catch (error) {
console.error(`Ошибка при проверке сервера ${server.id}:`, error);
return false;
}
});
// Ожидаем результаты всех проверок
const results = await Promise.all(checkPromises);
// Игрок онлайн, если хотя бы одна проверка вернула true
return results.some((result) => result);
} catch (error) {
console.error('Ошибка при проверке онлайн-статуса игрока:', error);
return false; // В случае ошибки считаем, что игрок не онлайн
}
}
/**
* Проверяет, находится ли указанный игрок онлайн на конкретном сервере
* @param username Имя игрока для проверки
* @param serverId ID сервера для проверки
* @returns {Promise<boolean>} true, если игрок онлайн на указанном сервере
*/
export async function isPlayerOnlineOnServer(
username: string,
serverId: string,
): Promise<boolean> {
try {
const onlinePlayers = await fetchOnlinePlayers(serverId);
return (
Array.isArray(onlinePlayers.online_players) &&
onlinePlayers.online_players.some(
(player) => player.username === username,
)
);
} catch (error) {
console.error(`Ошибка при проверке игрока на сервере ${serverId}:`, error);
return false;
}
}
/**
* Проверяет, на каком сервере находится игрок
* @param username Имя игрока для проверки
* @returns Объект с информацией: онлайн ли игрок и на каком сервере
*/
export async function getPlayerServer(
username: string,
): Promise<{ online: boolean; server: Server | null }> {
try {
// Получаем список активных серверов
const servers = await fetchActiveServers();
// Фильтруем серверы с игроками
const serversWithPlayers = servers.filter(
(server) => server.online_players > 0,
);
// Если нет серверов с игроками, игрок точно не онлайн
if (serversWithPlayers.length === 0) {
return { online: false, server: null };
}
// Проверяем каждый сервер на наличие игрока
for (const server of serversWithPlayers) {
try {
const onlinePlayers = await fetchOnlinePlayers(server.id);
if (
Array.isArray(onlinePlayers.online_players) &&
onlinePlayers.online_players.some(
(player) => player.username === username,
)
) {
// Игрок найден на этом сервере
return { online: true, server };
}
} catch (error) {
console.error(`Ошибка при проверке сервера ${server.id}:`, error);
}
}
// Игрок не найден ни на одном сервере
return { online: false, server: null };
} catch (error) {
console.error('Ошибка при проверке сервера игрока:', error);
return { online: false, server: null };
}
}