Compare commits
78 Commits
1.0.5
...
ee706a3fb0
| Author | SHA1 | Date | |
|---|---|---|---|
| ee706a3fb0 | |||
| d7d126f01f | |||
| 23308c8598 | |||
| 14905fcee7 | |||
| 833444df2e | |||
| 3e03c1024d | |||
| c6cceaf299 | |||
| a456925a08 | |||
| 52336f8960 | |||
| bbd0dd11b0 | |||
| 39f8ec875b | |||
| c14315b078 | |||
| 3ddcda2cec | |||
| 5efeb9a5c1 | |||
| 6a7169e2ae | |||
| 2e6b2d7add | |||
| 3e62bd7c27 | |||
| 48a0d0defb | |||
| 8c9e46a1ae | |||
| 215e3d6d39 | |||
| fc5e65f189 | |||
| 734ca4fce5 | |||
| e8ec4052ba | |||
| fcbc2352dc | |||
| 5deba6ca92 | |||
| fd6bb8b4db | |||
| 6665fca48d | |||
| 205bb84fec | |||
| 59c7d7fd85 | |||
| 65ea5418da | |||
| 5d660e7a95 | |||
| 83a0e308bc | |||
| 9746847ebf | |||
| f201aaa894 | |||
| 97c28c2b32 | |||
| 30c25452dc | |||
| aae4261b53 | |||
| 3b13d78cdc | |||
| d38faccf6f | |||
| 07caa7c53c | |||
| a2f42346ae | |||
| 932d867505 | |||
| 212b58c072 | |||
| 3d78d3e279 | |||
| e018aec8db | |||
| fa115e0a6c | |||
| 51b155e70a | |||
| 6ee1b67315 | |||
| c39a8bc43c | |||
| 56da7c7543 | |||
| 26f601635b | |||
| ec54219192 | |||
| f3aa32a60a | |||
| 7938555c91 | |||
| 591e354dcb | |||
| ce141a014c | |||
| 3d4c9c89ef | |||
| 90d4bb80e6 | |||
| fdf5c7a90d | |||
| 387d1548ba | |||
| 942066ea76 | |||
| 815ce286f7 | |||
| 31a26dc1ce | |||
| 5cd483209f | |||
| 1b50a7d4e4 | |||
| b14de1d15a | |||
| 7eaf7a7610 | |||
| 76917e3f90 | |||
| 261b9ac253 | |||
| 2eda1d7806 | |||
| 1b496288de | |||
| ff91303b18 | |||
| 8fa6956095 | |||
| 6f92b2acad | |||
| b65b9538bb | |||
| 4717132b05 | |||
| 12f7ea8d1c | |||
| e21a51482a |
1
.cursorignore
Normal file
1
.cursorignore
Normal file
@ -0,0 +1 @@
|
||||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
||||
@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Base webpack config used across other specific configs
|
||||
*/
|
||||
|
||||
import webpack from 'webpack';
|
||||
import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin';
|
||||
import webpackPaths from './webpack.paths';
|
||||
@ -20,7 +16,6 @@ const configuration: webpack.Configuration = {
|
||||
use: {
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
// Remove this line to enable type checking in webpack builds
|
||||
transpileOnly: true,
|
||||
compilerOptions: {
|
||||
module: 'nodenext',
|
||||
@ -34,18 +29,22 @@ const configuration: webpack.Configuration = {
|
||||
|
||||
output: {
|
||||
path: webpackPaths.srcPath,
|
||||
// https://github.com/webpack/webpack/issues/1114
|
||||
library: { type: 'commonjs2' },
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine the array of extensions that should be used to resolve modules.
|
||||
*/
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
|
||||
modules: [webpackPaths.srcPath, 'node_modules'],
|
||||
// There is no need to add aliases here, the paths in tsconfig get mirrored
|
||||
plugins: [new TsconfigPathsPlugins()],
|
||||
|
||||
// Новые настройки
|
||||
extensionAlias: {
|
||||
'.js': ['.js', '.mjs'],
|
||||
},
|
||||
fullySpecified: false,
|
||||
alias: {
|
||||
'undici/lib/core/util': 'undici/lib/core/util.js',
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [new webpack.EnvironmentPlugin({ NODE_ENV: 'production' })],
|
||||
|
||||
@ -36,9 +36,15 @@ const configuration: webpack.Configuration = {
|
||||
},
|
||||
|
||||
optimization: {
|
||||
minimize: false,
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
parallel: true,
|
||||
terserOptions: {
|
||||
ecma: 2020,
|
||||
keep_classnames: true,
|
||||
keep_fnames: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +1,5 @@
|
||||
public/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@ -27,3 +29,4 @@ npm-debug.log.*
|
||||
*.css.d.ts
|
||||
*.sass.d.ts
|
||||
*.scss.d.ts
|
||||
.env
|
||||
|
||||
0
assets/fonts/benzin-bold.eot
Normal file
0
assets/fonts/benzin-bold.eot
Normal file
1745
assets/fonts/benzin-bold.svg
Normal file
1745
assets/fonts/benzin-bold.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 221 KiB |
BIN
assets/fonts/benzin-bold.ttf
Normal file
BIN
assets/fonts/benzin-bold.ttf
Normal file
Binary file not shown.
0
assets/fonts/benzin-bold.woff
Normal file
0
assets/fonts/benzin-bold.woff
Normal file
BIN
assets/fonts/benzin-bold.woff2
Normal file
BIN
assets/fonts/benzin-bold.woff2
Normal file
Binary file not shown.
BIN
assets/icons/popa-popa.png
Normal file
BIN
assets/icons/popa-popa.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
18
assets/icons/popa-popa.svg
Normal file
18
assets/icons/popa-popa.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 80 KiB |
30
assets/images/heart.svg
Normal file
30
assets/images/heart.svg
Normal file
@ -0,0 +1,30 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="12" width="28" height="4" fill="#BD2211"/>
|
||||
<rect x="4" y="16" width="20" height="4" fill="#BD2211"/>
|
||||
<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 x="24" y="4" width="4" height="8" fill="#FF2D0F"/>
|
||||
<rect x="16" width="8" height="16" 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="20" y="16" width="4" height="4" fill="#BD2211"/>
|
||||
<rect x="16" y="20" width="4" height="4" fill="#BD2211"/>
|
||||
<rect x="12" y="24" width="4" height="4" fill="#BD2211"/>
|
||||
<rect x="8" y="20" 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 y="12" width="4" height="4" fill="#BD2211"/>
|
||||
<rect x="12" y="4" width="4" height="20" fill="#FF2D0F"/> -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
9375
package-lock.json
generated
9375
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
46
package.json
46
package.json
@ -18,7 +18,7 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/electron-react-boilerplate/electron-react-boilerplate.git"
|
||||
"url": "git+https://git.popa-popa.ru/DIKER/popa-launcher.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@ -42,18 +42,22 @@
|
||||
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll",
|
||||
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
||||
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll",
|
||||
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish always && npm run build:dll",
|
||||
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
|
||||
"prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.dev.ts",
|
||||
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer",
|
||||
"start:main": "concurrently -k -P \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon . -- {@}\" --",
|
||||
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
|
||||
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"publish-debug": "electron-builder build --publish always"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends browserslist-config-erb"
|
||||
],
|
||||
"overrides": {
|
||||
"undici": "6.10.2"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
@ -102,17 +106,39 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/notarize": "^3.0.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.2.0",
|
||||
"@mui/material": "^7.2.0",
|
||||
"@xmcl/core": "^2.14.1",
|
||||
"@xmcl/file-transfer": "^2.0.3",
|
||||
"@xmcl/installer": "^6.1.2",
|
||||
"@xmcl/resourcepack": "^1.2.4",
|
||||
"@xmcl/user": "^4.2.0",
|
||||
"easymde": "^2.20.0",
|
||||
"electron-debug": "^4.1.0",
|
||||
"electron-log": "^5.3.2",
|
||||
"electron-updater": "^6.3.9",
|
||||
"find-java-home": "^2.0.0",
|
||||
"https-browserify": "^1.0.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"qr-code-styling": "^1.9.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.3.0"
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"skinview3d": "^3.4.1",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"three": "^0.178.0",
|
||||
"util": "^0.12.5",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^3.7.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.12.9",
|
||||
"@teamsupercell/typings-for-css-modules-loader": "^2.5.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
@ -121,6 +147,7 @@
|
||||
"@types/react": "^19.0.11",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-test-renderer": "^19.0.0",
|
||||
"@types/three": "^0.178.1",
|
||||
"@types/webpack-bundle-analyzer": "^4.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
"@typescript-eslint/parser": "^8.26.1",
|
||||
@ -175,7 +202,7 @@
|
||||
"webpack-merge": "^6.0.1"
|
||||
},
|
||||
"build": {
|
||||
"productName": "ElectronReact",
|
||||
"productName": "popa-launcher",
|
||||
"appId": "org.erb.ElectronReact",
|
||||
"asar": true,
|
||||
"afterSign": ".erb/scripts/notarize.js",
|
||||
@ -234,9 +261,12 @@
|
||||
"./assets/**"
|
||||
],
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "electron-react-boilerplate",
|
||||
"repo": "electron-react-boilerplate"
|
||||
"provider": "generic",
|
||||
"url": "https://git.popa-popa.ru/DIKER/popa-launcher/releases/download/v${version}",
|
||||
"channel": "latest",
|
||||
"requestHeaders": {
|
||||
"Authorization": "token ${env.GH_TOKEN}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collective": {
|
||||
|
||||
8
release/app/package-lock.json
generated
8
release/app/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "electron-react-boilerplate",
|
||||
"version": "4.6.0",
|
||||
"name": "popa-launcher",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "electron-react-boilerplate",
|
||||
"version": "4.6.0",
|
||||
"name": "popa-launcher",
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "electron-react-boilerplate",
|
||||
"version": "4.6.0",
|
||||
"description": "A foundation for scalable desktop apps",
|
||||
"name": "popa-launcher",
|
||||
"version": "1.0.0",
|
||||
"description": "Popa Launcher",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Electron React Boilerplate Maintainers",
|
||||
"email": "electronreactboilerplate@gmail.com",
|
||||
"url": "https://github.com/electron-react-boilerplate"
|
||||
"name": "DIKER",
|
||||
"email": "diker0k@gmail.com",
|
||||
"url": "https://github.com/DIKER0K"
|
||||
},
|
||||
"main": "./dist/main/main.js",
|
||||
"scripts": {
|
||||
|
||||
92
src/main/auth-service.ts
Normal file
92
src/main/auth-service.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { YggdrasilClient, YggrasilAuthentication } from '@xmcl/user';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { API_BASE_URL } from '../renderer/api';
|
||||
|
||||
// Ely.by сервер
|
||||
const ELY_BY_AUTH_SERVER = API_BASE_URL;
|
||||
|
||||
export class AuthService {
|
||||
private client: YggdrasilClient;
|
||||
|
||||
constructor() {
|
||||
this.client = new YggdrasilClient(ELY_BY_AUTH_SERVER);
|
||||
}
|
||||
|
||||
async login(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<YggrasilAuthentication> {
|
||||
try {
|
||||
// Генерируем уникальный clientToken
|
||||
const clientToken = uuidv4();
|
||||
|
||||
// Выполняем запрос напрямую к правильному URL
|
||||
const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/authenticate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
clientToken,
|
||||
requestUser: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Ошибка авторизации: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const auth = await response.json();
|
||||
console.log(`Аутентификация успешна для ${auth.selectedProfile?.name}`);
|
||||
return auth;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при авторизации:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async validate(accessToken: string, clientToken: string): Promise<boolean> {
|
||||
try {
|
||||
console.log(accessToken, clientToken);
|
||||
const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ accessToken, clientToken }),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при валидации токена:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(
|
||||
accessToken: string,
|
||||
clientToken: string,
|
||||
): Promise<YggrasilAuthentication | null> {
|
||||
try {
|
||||
const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ accessToken, clientToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении токена:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,12 +14,47 @@ import { autoUpdater } from 'electron-updater';
|
||||
import log from 'electron-log';
|
||||
import MenuBuilder from './menu';
|
||||
import { resolveHtmlPath } from './util';
|
||||
import {
|
||||
initMinecraftHandlers,
|
||||
initAuthHandlers,
|
||||
initServerStatusHandler,
|
||||
initPackConfigHandlers,
|
||||
} from './minecraft-launcher';
|
||||
|
||||
class AppUpdater {
|
||||
constructor() {
|
||||
log.transports.file.level = 'info';
|
||||
autoUpdater.logger = log;
|
||||
|
||||
const server = 'https://git.popa-popa.ru/DIKER/popa-launcher';
|
||||
|
||||
// Для Gitea нужно указать конкретную структуру URL
|
||||
// Обратите внимание на использование пути /download/
|
||||
autoUpdater.setFeedURL({
|
||||
provider: 'generic',
|
||||
url: `${server}/releases/download/latest`, // Укажите конкретную версию
|
||||
channel: 'latest',
|
||||
});
|
||||
|
||||
// Проверка обновлений
|
||||
autoUpdater.checkForUpdatesAndNotify();
|
||||
|
||||
// Периодическая проверка обновлений (каждый час)
|
||||
setInterval(
|
||||
() => {
|
||||
autoUpdater.checkForUpdatesAndNotify();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Обработчики событий обновления
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
log.info('Обновление загружено. Будет установлено при перезапуске.');
|
||||
// Можно отправить событие в renderer для уведомления пользователя
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('update-available');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,6 +78,16 @@ if (isDebug) {
|
||||
require('electron-debug').default();
|
||||
}
|
||||
|
||||
ipcMain.handle('close-app', () => {
|
||||
app.quit();
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('minimize-app', () => {
|
||||
mainWindow?.minimize();
|
||||
return true;
|
||||
});
|
||||
|
||||
const installExtensions = async () => {
|
||||
const installer = require('electron-devtools-installer');
|
||||
const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
|
||||
@ -72,9 +117,13 @@ const createWindow = async () => {
|
||||
mainWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 1024,
|
||||
height: 728,
|
||||
height: 850,
|
||||
autoHideMenuBar: true,
|
||||
resizable: true,
|
||||
frame: false,
|
||||
icon: getAssetPath('icon.png'),
|
||||
webPreferences: {
|
||||
webSecurity: false,
|
||||
preload: app.isPackaged
|
||||
? path.join(__dirname, 'preload.js')
|
||||
: path.join(__dirname, '../../.erb/dll/preload.js'),
|
||||
@ -110,6 +159,11 @@ const createWindow = async () => {
|
||||
// Remove this if your app does not use auto updates
|
||||
// eslint-disable-next-line
|
||||
new AppUpdater();
|
||||
|
||||
initAuthHandlers();
|
||||
initMinecraftHandlers();
|
||||
initServerStatusHandler();
|
||||
initPackConfigHandlers();
|
||||
};
|
||||
|
||||
/**
|
||||
@ -135,3 +189,7 @@ app
|
||||
});
|
||||
})
|
||||
.catch(console.log);
|
||||
|
||||
ipcMain.handle('install-update', () => {
|
||||
autoUpdater.quitAndInstall();
|
||||
});
|
||||
|
||||
1300
src/main/minecraft-launcher.ts
Normal file
1300
src/main/minecraft-launcher.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,25 @@
|
||||
// Disable no-unused-vars, broken for spread args
|
||||
/* eslint no-unused-vars: off */
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
|
||||
export type Channels = 'ipc-example';
|
||||
export type Channels =
|
||||
| 'ipc-example'
|
||||
| 'download-progress'
|
||||
| 'launch-minecraft'
|
||||
| 'installation-status'
|
||||
| 'get-server-status'
|
||||
| 'close-app'
|
||||
| 'minimize-app'
|
||||
| 'save-pack-config'
|
||||
| 'load-pack-config'
|
||||
| 'update-available'
|
||||
| 'install-update'
|
||||
| 'get-installed-versions'
|
||||
| 'get-available-versions'
|
||||
| 'minecraft-log'
|
||||
| 'minecraft-error'
|
||||
| 'overall-progress'
|
||||
| 'stop-minecraft'
|
||||
| 'minecraft-started'
|
||||
| 'minecraft-stopped';
|
||||
|
||||
const electronHandler = {
|
||||
ipcRenderer: {
|
||||
@ -21,6 +38,9 @@ const electronHandler = {
|
||||
once(channel: Channels, func: (...args: unknown[]) => void) {
|
||||
ipcRenderer.once(channel, (_event, ...args) => func(...args));
|
||||
},
|
||||
invoke(channel: Channels, ...args: unknown[]): Promise<any> {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -1,62 +1,97 @@
|
||||
/*
|
||||
* @NOTE: Prepend a `~` to css file paths that are in your node_modules
|
||||
* See https://github.com/webpack-contrib/sass-loader#imports
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Benzin-Bold';
|
||||
src: url('../../assets/fonts/benzin-bold.eot'); /* IE 9 Compatibility Mode */
|
||||
src:
|
||||
url('../../assets/fonts/benzin-bold.eot?#iefix') format('embedded-opentype'),
|
||||
/* IE < 9 */ url('../../assets/fonts/benzin-bold.woff2') format('woff2'),
|
||||
/* Super Modern Browsers */ url('../../assets/fonts/benzin-bold.woff')
|
||||
format('woff'),
|
||||
/* Firefox >= 3.6, any other modern browser */
|
||||
url('../../assets/fonts/benzin-bold.ttf') format('truetype'),
|
||||
/* Safari, Android, iOS */
|
||||
url('../../assets/fonts/benzin-bold.svg#benzin-bold') format('svg'); /* Chrome < 4, Legacy iOS */
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
color: white;
|
||||
height: 100vh;
|
||||
background: linear-gradient(
|
||||
200.96deg,
|
||||
#fedc2a -29.09%,
|
||||
#dd5789 51.77%,
|
||||
#7a2c9e 129.35%
|
||||
);
|
||||
font-family: sans-serif;
|
||||
background: linear-gradient(242.94deg, #000000 39.07%, #3b4187 184.73%);
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: white;
|
||||
padding: 10px 20px;
|
||||
p {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* трек */
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 100px;
|
||||
margin: 20px 0; /* ⬅– отступы сверху и снизу */
|
||||
}
|
||||
|
||||
/* Бегунок */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(71deg, #f27121 0%, #e940cd 70%, #8a2387 100%);
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
appearance: none;
|
||||
font-size: 1.3rem;
|
||||
box-shadow: 0px 8px 28px -6px rgba(24, 39, 75, 0.12),
|
||||
0px 18px 88px -4px rgba(24, 39, 75, 0.14);
|
||||
transition: all ease-in 0.1s;
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: scale(1.05);
|
||||
opacity: 1;
|
||||
/* hover эффект */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-size: 400% 400%;
|
||||
animation-duration: 1.7s;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
height: fit-content;
|
||||
width: fit-content;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.Hello {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
/* shimmer-анимация градиента */
|
||||
@keyframes scrollbarShimmer {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,50 +1,191 @@
|
||||
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import icon from '../../assets/icon.svg';
|
||||
import {
|
||||
MemoryRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import LaunchPage from './pages/LaunchPage';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import './App.css';
|
||||
import TopBar from './components/TopBar';
|
||||
import { Box } from '@mui/material';
|
||||
import MinecraftBackground from './components/MinecraftBackground';
|
||||
import { Notifier } from './components/Notifier';
|
||||
import { VersionsExplorer } from './pages/VersionsExplorer';
|
||||
import Profile from './pages/Profile';
|
||||
import Shop from './pages/Shop';
|
||||
import Marketplace from './pages/Marketplace';
|
||||
import { Registration } from './pages/Registration';
|
||||
import { FullScreenLoader } from './components/FullScreenLoader';
|
||||
import { News } from './pages/News';
|
||||
|
||||
function Hello() {
|
||||
return (
|
||||
<div>
|
||||
<div className="Hello">
|
||||
<img width="200" alt="icon" src={icon} />
|
||||
</div>
|
||||
<h1>electron-react-boilerplate</h1>
|
||||
<div className="Hello">
|
||||
<a
|
||||
href="https://electron-react-boilerplate.js.org/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<button type="button">
|
||||
<span role="img" aria-label="books">
|
||||
📚
|
||||
</span>
|
||||
Read our docs
|
||||
</button>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/sponsors/electron-react-boilerplate"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<button type="button">
|
||||
<span role="img" aria-label="folded hands">
|
||||
🙏
|
||||
</span>
|
||||
Donate
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
if (savedConfig) {
|
||||
const config = JSON.parse(savedConfig);
|
||||
if (config.accessToken) {
|
||||
// Можно добавить дополнительную проверку токена
|
||||
const isValid = await validateToken(
|
||||
config.accessToken,
|
||||
config.clientToken,
|
||||
);
|
||||
setIsAuthenticated(isValid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setIsAuthenticated(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки авторизации:', error);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const validateToken = async (accessToken: string, clientToken: string) => {
|
||||
try {
|
||||
// Используем IPC для валидации токена через main процесс
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'validate-token',
|
||||
{ accessToken, clientToken },
|
||||
);
|
||||
|
||||
// Если токен недействителен, очищаем сохраненные данные в localStorage
|
||||
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) {
|
||||
console.error('Ошибка проверки токена:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuthenticated === null) {
|
||||
return <FullScreenLoader message="Загрузка..." />;
|
||||
}
|
||||
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
// Просто используйте window.open без useNavigate
|
||||
const handleRegister = () => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Hello />} />
|
||||
</Routes>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
<MinecraftBackground />
|
||||
<TopBar onRegister={handleRegister} username={username || ''} />
|
||||
<Notifier />
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={<Login onLoginSuccess={setUsername} />}
|
||||
/>
|
||||
<Route path="/registration" element={<Registration />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/news"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<News />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Box>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
1008
src/renderer/api.ts
Normal file
1008
src/renderer/api.ts
Normal file
File diff suppressed because it is too large
Load Diff
308
src/renderer/components/BonusShopItem.tsx
Normal file
308
src/renderer/components/BonusShopItem.tsx
Normal file
@ -0,0 +1,308 @@
|
||||
// src/renderer/components/BonusShopItem.tsx
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
CardMedia,
|
||||
} from '@mui/material';
|
||||
import CoinsDisplay from './CoinsDisplay';
|
||||
|
||||
export interface BonusShopItemProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
|
||||
level: number;
|
||||
effectValue: number;
|
||||
nextEffectValue?: number;
|
||||
|
||||
// цена покупки и улучшения
|
||||
price?: number;
|
||||
upgradePrice: number;
|
||||
canUpgrade: boolean;
|
||||
|
||||
mode?: 'buy' | 'upgrade';
|
||||
|
||||
isActive?: boolean;
|
||||
isPermanent?: boolean;
|
||||
|
||||
imageUrl?: string;
|
||||
|
||||
disabled?: boolean;
|
||||
|
||||
onBuy?: () => void;
|
||||
onUpgrade?: () => void;
|
||||
onToggleActive?: () => void;
|
||||
}
|
||||
|
||||
export const BonusShopItem: React.FC<BonusShopItemProps> = ({
|
||||
name,
|
||||
description,
|
||||
level,
|
||||
effectValue,
|
||||
nextEffectValue,
|
||||
price,
|
||||
upgradePrice,
|
||||
canUpgrade,
|
||||
mode,
|
||||
isActive = true,
|
||||
isPermanent = false,
|
||||
imageUrl,
|
||||
disabled,
|
||||
onBuy,
|
||||
onUpgrade,
|
||||
onToggleActive,
|
||||
}) => {
|
||||
const isBuyMode = mode === 'buy' || level === 0;
|
||||
const buttonText = isBuyMode
|
||||
? 'Купить'
|
||||
: canUpgrade
|
||||
? 'Улучшить'
|
||||
: 'Макс. уровень';
|
||||
const displayedPrice = isBuyMode ? (price ?? upgradePrice) : upgradePrice;
|
||||
|
||||
const buttonDisabled =
|
||||
disabled ||
|
||||
(isBuyMode
|
||||
? !onBuy || displayedPrice === undefined
|
||||
: !canUpgrade || !onUpgrade);
|
||||
|
||||
const handlePrimaryClick = () => {
|
||||
if (buttonDisabled) return;
|
||||
if (isBuyMode && onBuy) onBuy();
|
||||
else if (!isBuyMode && onUpgrade) onUpgrade();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: 300,
|
||||
height: 440,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
background: 'rgba(20,20,20,0.9)',
|
||||
borderRadius: '2.5vw',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
boxShadow: '0 10px 40px rgba(0,0,0,0.8)',
|
||||
overflow: 'hidden',
|
||||
|
||||
transition: 'transform 0.35s ease, box-shadow 0.35s ease',
|
||||
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 20px 60px rgba(242,113,33,0.45)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Градиентный свет сверху — как в ShopItem */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
background:
|
||||
'radial-gradient(circle at top, rgba(242,113,33,0.25), transparent 60%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{imageUrl && (
|
||||
<Box sx={{ position: 'relative', p: 1.5, pb: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '1.8vw',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(40,40,40,0.9), rgba(15,15,15,0.9))',
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={imageUrl}
|
||||
alt={name}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 160,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<CardContent
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
pt: 2,
|
||||
pb: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
{/* Имя бонуса — градиентом как у ShopItem */}
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1.05rem',
|
||||
mb: 0.5,
|
||||
backgroundImage:
|
||||
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: '0.8rem',
|
||||
mb: 0.8,
|
||||
}}
|
||||
>
|
||||
Уровень: {level}
|
||||
{isPermanent && ' • Постоянный'}
|
||||
</Typography>
|
||||
|
||||
{description && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
fontSize: '0.85rem',
|
||||
mb: 1.2,
|
||||
minHeight: 40,
|
||||
maxHeight: 40,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography
|
||||
sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.8rem' }}
|
||||
>
|
||||
Текущий эффект:{' '}
|
||||
<Box component="b" sx={{ fontWeight: 600 }}>
|
||||
{effectValue.toLocaleString('ru-RU')}
|
||||
</Box>
|
||||
</Typography>
|
||||
|
||||
{typeof nextEffectValue === 'number' &&
|
||||
!isBuyMode &&
|
||||
canUpgrade && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: '0.8rem',
|
||||
mt: 0.4,
|
||||
}}
|
||||
>
|
||||
Следующий уровень:{' '}
|
||||
<Box component="b" sx={{ fontWeight: 600 }}>
|
||||
{nextEffectValue.toLocaleString('ru-RU')}
|
||||
</Box>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.78rem',
|
||||
mb: 1,
|
||||
color: isActive
|
||||
? 'rgba(0, 200, 140, 0.9)'
|
||||
: 'rgba(255, 180, 80, 0.9)',
|
||||
}}
|
||||
>
|
||||
{isActive ? 'Бонус активен' : 'Бонус не активен'}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.85rem' }}
|
||||
>
|
||||
{isBuyMode ? 'Цена покупки' : 'Цена улучшения'}
|
||||
</Typography>
|
||||
{displayedPrice !== undefined && (
|
||||
<CoinsDisplay
|
||||
value={displayedPrice}
|
||||
size="small"
|
||||
autoUpdate={false}
|
||||
showTooltip={true}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!isBuyMode && onToggleActive && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onToggleActive}
|
||||
disabled={disabled}
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
borderRadius: '2.5vw',
|
||||
textTransform: 'none',
|
||||
fontSize: '0.75rem',
|
||||
px: 2,
|
||||
borderColor: 'rgba(255,255,255,0.4)',
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255,255,255,0.8)',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isActive ? 'Выключить' : 'Включить'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Кнопка в стиле Registration / ShopItem */}
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={buttonDisabled}
|
||||
onClick={handlePrimaryClick}
|
||||
sx={{
|
||||
mt: 2,
|
||||
transition: 'transform 0.3s ease, opacity 0.2s ease',
|
||||
background: buttonDisabled
|
||||
? 'linear-gradient(71deg, #555 0%, #666 70%, #444 100%)'
|
||||
: 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderRadius: '2.5vw',
|
||||
fontSize: '0.85rem',
|
||||
color: '#fff',
|
||||
opacity: buttonDisabled ? 0.6 : 1,
|
||||
'&:hover': {
|
||||
transform: buttonDisabled ? 'none' : 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BonusShopItem;
|
||||
134
src/renderer/components/CapeCard.tsx
Normal file
134
src/renderer/components/CapeCard.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
// src/renderer/components/CapeCard.tsx
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
Typography,
|
||||
CardActions,
|
||||
Button,
|
||||
Tooltip,
|
||||
Box,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import CustomTooltip from './CustomTooltip';
|
||||
// Тип для плаща с необязательными полями для обоих вариантов использования
|
||||
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 (
|
||||
<CustomTooltip arrow title={capeDescription}>
|
||||
<Card
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
width: 200,
|
||||
overflow: 'hidden',
|
||||
position: 'relative', // для позиционирования ценника
|
||||
borderRadius: '1vw',
|
||||
}}
|
||||
>
|
||||
{/* Ценник для магазина */}
|
||||
{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: '6vw',
|
||||
minHeight: '5vw',
|
||||
}}
|
||||
>
|
||||
<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: '2vw',
|
||||
p: '0.5vw 2.5vw',
|
||||
color: 'white',
|
||||
backgroundColor: 'rgb(0, 134, 0)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 134, 0, 0.5)',
|
||||
},
|
||||
fontFamily: 'Benzin-Bold',
|
||||
}}
|
||||
>
|
||||
{actionButton.text}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</CustomTooltip>
|
||||
);
|
||||
}
|
||||
39
src/renderer/components/CapePreview.tsx
Normal file
39
src/renderer/components/CapePreview.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
interface CapePreviewProps {
|
||||
imageUrl: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export const CapePreview: React.FC<CapePreviewProps> = ({
|
||||
imageUrl,
|
||||
alt = 'Плащ',
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 140, // фиксированная область под плащ
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
imageRendering: 'pixelated',
|
||||
|
||||
// Берём старый "зум" из CapeCard — плащ становится большим,
|
||||
// а лишнее обрезается контейнером.
|
||||
transform: 'scale(2.9) translateX(0px) translateY(0px)',
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
421
src/renderer/components/CaseRoulette.tsx
Normal file
421
src/renderer/components/CaseRoulette.tsx
Normal file
@ -0,0 +1,421 @@
|
||||
import { Box, Typography, Button, Dialog, DialogContent } from '@mui/material';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { CaseItem } from '../api';
|
||||
|
||||
type Rarity = 'common' | 'rare' | 'epic' | 'legendary';
|
||||
|
||||
interface CaseRouletteProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
caseName?: string;
|
||||
items: CaseItem[];
|
||||
reward: CaseItem | null;
|
||||
}
|
||||
|
||||
// --- настройки рулетки ---
|
||||
const ITEM_WIDTH = 110;
|
||||
const ITEM_GAP = 8;
|
||||
const VISIBLE_ITEMS = 21;
|
||||
const CONTAINER_WIDTH = 800;
|
||||
const LINE_X = CONTAINER_WIDTH / 2;
|
||||
|
||||
const ANIMATION_DURATION = 10; // секунды
|
||||
const ANIMATION_DURATION_MS = ANIMATION_DURATION * 1000;
|
||||
|
||||
// Удаляем майнкрафтовские цвет-коды (§a, §b, §l и т.д.)
|
||||
function stripMinecraftColors(text?: string | null): string {
|
||||
if (!text) return '';
|
||||
return text.replace(/§[0-9A-FK-ORa-fk-or]/g, '');
|
||||
}
|
||||
|
||||
function getRarityByWeight(weight?: number): Rarity {
|
||||
if (weight === undefined || weight === null) return 'common';
|
||||
if (weight <= 5) return 'legendary';
|
||||
if (weight <= 20) return 'epic';
|
||||
if (weight <= 50) return 'rare';
|
||||
return 'common';
|
||||
}
|
||||
|
||||
function getRarityColor(weight?: number): string {
|
||||
const rarity = getRarityByWeight(weight);
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return 'rgba(255, 215, 0, 1)';
|
||||
case 'epic':
|
||||
return 'rgba(186, 85, 211, 1)';
|
||||
case 'rare':
|
||||
return 'rgba(65, 105, 225, 1)';
|
||||
case 'common':
|
||||
default:
|
||||
return 'rgba(255, 255, 255, 0.6)';
|
||||
}
|
||||
}
|
||||
|
||||
export default function CaseRoulette({
|
||||
open,
|
||||
onClose,
|
||||
caseName,
|
||||
items,
|
||||
reward,
|
||||
}: CaseRouletteProps) {
|
||||
const [sequence, setSequence] = useState<CaseItem[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [animationFinished, setAnimationFinished] = useState(false);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const animationTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const finishTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const winningNameRaw =
|
||||
reward?.meta?.display_name || reward?.name || reward?.material || '';
|
||||
const winningName = stripMinecraftColors(winningNameRaw);
|
||||
|
||||
// Измеряем реальные ширины элементов
|
||||
const measureItemWidths = useCallback((): number[] => {
|
||||
return itemRefs.current.map((ref) =>
|
||||
ref ? ref.getBoundingClientRect().width : ITEM_WIDTH,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Основной эффект для инициализации
|
||||
useEffect(() => {
|
||||
if (!open || !reward || !items || items.length === 0) return;
|
||||
|
||||
if (animationTimeoutRef.current) clearTimeout(animationTimeoutRef.current);
|
||||
if (finishTimeoutRef.current) clearTimeout(finishTimeoutRef.current);
|
||||
|
||||
setAnimating(false);
|
||||
setAnimationFinished(false);
|
||||
setOffset(0);
|
||||
itemRefs.current = [];
|
||||
|
||||
const totalItems = VISIBLE_ITEMS * 3;
|
||||
const seq: CaseItem[] = [];
|
||||
|
||||
for (let i = 0; i < totalItems; i++) {
|
||||
const randomItem = items[Math.floor(Math.random() * items.length)];
|
||||
seq.push(randomItem);
|
||||
}
|
||||
|
||||
const winPosition = Math.floor(totalItems / 2);
|
||||
const fromCase =
|
||||
items.find((i) => i.material === reward.material) || reward;
|
||||
seq[winPosition] = fromCase;
|
||||
|
||||
setSequence(seq);
|
||||
}, [open, reward, items]);
|
||||
|
||||
// Эффект запуска анимации
|
||||
useEffect(() => {
|
||||
if (sequence.length === 0 || !open) return;
|
||||
|
||||
const startAnimation = () => {
|
||||
const widths = measureItemWidths();
|
||||
const winPosition = Math.floor(sequence.length / 2);
|
||||
|
||||
const EXTRA_SPINS = 3;
|
||||
const averageItemWidth = ITEM_WIDTH + ITEM_GAP;
|
||||
const extraDistance = EXTRA_SPINS * VISIBLE_ITEMS * averageItemWidth;
|
||||
|
||||
if (widths.length === 0 || widths.length !== sequence.length) {
|
||||
const centerItemCenter =
|
||||
winPosition * (ITEM_WIDTH + ITEM_GAP) + ITEM_WIDTH / 2;
|
||||
|
||||
const finalOffset = centerItemCenter - LINE_X;
|
||||
const initialOffset = Math.max(finalOffset - extraDistance, 0);
|
||||
|
||||
setOffset(initialOffset);
|
||||
|
||||
animationTimeoutRef.current = setTimeout(() => {
|
||||
setAnimating(true);
|
||||
setOffset(finalOffset);
|
||||
}, 50);
|
||||
|
||||
finishTimeoutRef.current = setTimeout(() => {
|
||||
setAnimationFinished(true);
|
||||
}, ANIMATION_DURATION_MS + 200);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let cumulativeOffset = 0;
|
||||
for (let i = 0; i < winPosition; i++) {
|
||||
cumulativeOffset += widths[i] + ITEM_GAP;
|
||||
}
|
||||
const centerItemCenter = cumulativeOffset + widths[winPosition] / 2;
|
||||
|
||||
const finalOffset = centerItemCenter - LINE_X;
|
||||
const initialOffset = Math.max(finalOffset - extraDistance, 0);
|
||||
|
||||
setOffset(initialOffset);
|
||||
|
||||
animationTimeoutRef.current = setTimeout(() => {
|
||||
setAnimating(true);
|
||||
setOffset(finalOffset);
|
||||
}, 50);
|
||||
|
||||
finishTimeoutRef.current = setTimeout(() => {
|
||||
setAnimationFinished(true);
|
||||
}, ANIMATION_DURATION_MS + 200);
|
||||
};
|
||||
|
||||
const renderTimeout = setTimeout(startAnimation, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(renderTimeout);
|
||||
if (animationTimeoutRef.current)
|
||||
clearTimeout(animationTimeoutRef.current);
|
||||
if (finishTimeoutRef.current) clearTimeout(finishTimeoutRef.current);
|
||||
};
|
||||
}, [sequence, open, measureItemWidths]);
|
||||
|
||||
// Очистка при закрытии
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (animationTimeoutRef.current)
|
||||
clearTimeout(animationTimeoutRef.current);
|
||||
if (finishTimeoutRef.current) clearTimeout(finishTimeoutRef.current);
|
||||
setSequence([]);
|
||||
setAnimating(false);
|
||||
setAnimationFinished(false);
|
||||
setOffset(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: 'transparent',
|
||||
borderRadius: '2.5vw',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 30px 80px rgba(0,0,0,0.9)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
sx={{
|
||||
position: 'relative',
|
||||
px: 3,
|
||||
py: 3.5,
|
||||
background:
|
||||
'radial-gradient(circle at top, #101018 0%, #050509 40%, #000 100%)',
|
||||
}}
|
||||
>
|
||||
{/* лёгкий "бордер" по контуру */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: '2.5vw',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* заголовок с градиентом как в Registration */}
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
mb: 2.5,
|
||||
fontFamily: 'Benzin-Bold',
|
||||
letterSpacing: 0.6,
|
||||
backgroundImage:
|
||||
'linear-gradient(136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
Открытие кейса {caseName}
|
||||
</Typography>
|
||||
|
||||
{/* контейнер рулетки */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '2vw',
|
||||
px: 2,
|
||||
py: 3,
|
||||
width: `${CONTAINER_WIDTH}px`,
|
||||
maxWidth: '100%',
|
||||
mx: 'auto',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(15,15,20,0.96), rgba(30,20,35,0.96))',
|
||||
boxShadow: '0 0 40px rgba(0,0,0,0.8)',
|
||||
}}
|
||||
>
|
||||
{/* затемнённые края */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
background:
|
||||
'linear-gradient(90deg, rgba(0,0,0,0.85) 0%, transparent 20%, transparent 80%, rgba(0,0,0,0.85) 100%)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* центральная линия (прицел) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: `${LINE_X}px`,
|
||||
transform: 'translateX(-1px)',
|
||||
width: '2px',
|
||||
background:
|
||||
'linear-gradient(180deg, rgb(242,113,33), rgb(233,64,87))',
|
||||
boxShadow: '0 0 16px rgba(233,64,87,0.9)',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Лента предметов */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: `${ITEM_GAP}px`,
|
||||
transform: `translateX(-${offset}px)`,
|
||||
willChange: 'transform',
|
||||
transition: animating
|
||||
? `transform ${ANIMATION_DURATION}s cubic-bezier(0.15, 0.85, 0.25, 1)`
|
||||
: 'none',
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
{sequence.map((item, index) => {
|
||||
const color = getRarityColor(item.weight);
|
||||
const isWinningItem =
|
||||
animationFinished && index === Math.floor(sequence.length / 2);
|
||||
|
||||
const rawName =
|
||||
item.meta?.display_name ||
|
||||
item.name ||
|
||||
item.material
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
const displayName = stripMinecraftColors(rawName);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
ref={(el) => (itemRefs.current[index] = el)}
|
||||
sx={{
|
||||
minWidth: `${ITEM_WIDTH}px`,
|
||||
height: 130,
|
||||
borderRadius: '1.4vw',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: isWinningItem
|
||||
? `2px solid ${color}`
|
||||
: `1px solid ${color}`,
|
||||
boxShadow: isWinningItem
|
||||
? `0 0 24px ${color}`
|
||||
: '0 0 10px rgba(0,0,0,0.6)',
|
||||
transition: 'all 0.3s ease',
|
||||
px: 1,
|
||||
transform: isWinningItem ? 'scale(1.08)' : 'scale(1)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={`https://cdn.minecraft.popa-popa.ru/textures/${item.material.toLowerCase()}.png`}
|
||||
alt={item.material}
|
||||
sx={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
objectFit: 'contain',
|
||||
imageRendering: 'pixelated',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'white',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.72rem',
|
||||
maxWidth: 100,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{animationFinished && winningName && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
mt: 2.5,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
}}
|
||||
>
|
||||
Вам выпало:{' '}
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
backgroundImage:
|
||||
'linear-gradient(136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{winningName}
|
||||
</Box>
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* кнопка как в Registration */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
transition: 'transform 0.3s ease',
|
||||
borderRadius: '2.5vw',
|
||||
px: '3vw',
|
||||
py: '0.7vw',
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '0.9rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: '#fff',
|
||||
opacity: animationFinished ? 1 : 0.4,
|
||||
pointerEvents: animationFinished ? 'auto' : 'none',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</Button>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
233
src/renderer/components/CoinsDisplay.tsx
Normal file
233
src/renderer/components/CoinsDisplay.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
// CoinsDisplay.tsx
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import CustomTooltip from './CustomTooltip';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchCoins } from '../api';
|
||||
|
||||
interface CoinsDisplayProps {
|
||||
// Основные пропсы
|
||||
value?: number; // Передаем значение напрямую
|
||||
username?: string; // Или получаем по username из API
|
||||
|
||||
// Опции отображения
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
showTooltip?: boolean;
|
||||
tooltipText?: string;
|
||||
showIcon?: boolean;
|
||||
iconColor?: string;
|
||||
|
||||
// Опции обновления
|
||||
autoUpdate?: boolean; // Автоматическое обновление из API
|
||||
updateInterval?: number; // Интервал обновления в миллисекундах
|
||||
|
||||
// Стилизация
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
export default function CoinsDisplay({
|
||||
// Основные пропсы
|
||||
value: externalValue,
|
||||
username,
|
||||
|
||||
// Опции отображения
|
||||
size = 'medium',
|
||||
showTooltip = true,
|
||||
tooltipText = 'Попы — внутриигровая валюта, начисляемая за время игры на серверах.',
|
||||
showIcon = true,
|
||||
iconColor = '#2bff00ff',
|
||||
|
||||
// Опции обновления
|
||||
autoUpdate = false,
|
||||
updateInterval = 60000,
|
||||
|
||||
// Стилизация
|
||||
backgroundColor = 'rgba(0, 0, 0, 0.2)',
|
||||
textColor = 'white',
|
||||
}: CoinsDisplayProps) {
|
||||
const [coins, setCoins] = useState<number>(externalValue || 0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
// Определяем размеры в зависимости от параметра size
|
||||
const getSizes = () => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return {
|
||||
containerPadding: '4px 8px',
|
||||
iconSize: '16px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '12px',
|
||||
gap: '6px',
|
||||
};
|
||||
case 'large':
|
||||
return {
|
||||
containerPadding: '8px 16px',
|
||||
iconSize: '28px',
|
||||
fontSize: '18px',
|
||||
borderRadius: '20px',
|
||||
gap: '10px',
|
||||
};
|
||||
case 'medium':
|
||||
default:
|
||||
return {
|
||||
containerPadding: '6px 12px',
|
||||
iconSize: '24px',
|
||||
fontSize: '16px',
|
||||
borderRadius: '16px',
|
||||
gap: '8px',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const sizes = getSizes();
|
||||
|
||||
// Функция для получения количества монет из API
|
||||
const fetchCoinsData = async () => {
|
||||
if (!username) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const coinsData = await fetchCoins(username);
|
||||
setCoins(coinsData.coins);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении количества монет:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Эффект для внешнего значения
|
||||
useEffect(() => {
|
||||
if (externalValue !== undefined) {
|
||||
setCoins(externalValue);
|
||||
}
|
||||
}, [externalValue]);
|
||||
|
||||
// Эффект для API обновлений
|
||||
useEffect(() => {
|
||||
if (username && autoUpdate) {
|
||||
fetchCoinsData();
|
||||
|
||||
// Создаем интервалы для периодического обновления данных
|
||||
const coinsInterval = setInterval(fetchCoinsData, updateInterval);
|
||||
|
||||
return () => {
|
||||
clearInterval(coinsInterval);
|
||||
};
|
||||
}
|
||||
}, [username, autoUpdate, updateInterval]);
|
||||
|
||||
// Ручное обновление данных
|
||||
const handleRefresh = () => {
|
||||
if (username) {
|
||||
fetchCoinsData();
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование числа с разделителями тысяч
|
||||
const formatNumber = (num: number): string => {
|
||||
return num.toLocaleString('ru-RU');
|
||||
};
|
||||
|
||||
const coinsDisplay = (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: sizes.gap,
|
||||
backgroundColor,
|
||||
borderRadius: sizes.borderRadius,
|
||||
padding: sizes.containerPadding,
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
cursor: showTooltip ? 'help' : 'default',
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
onClick={username ? handleRefresh : undefined}
|
||||
title={username ? 'Нажмите для обновления' : undefined}
|
||||
>
|
||||
{showIcon && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: sizes.iconSize,
|
||||
height: sizes.iconSize,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: iconColor,
|
||||
fontWeight: 'bold',
|
||||
fontSize: `calc(${sizes.fontSize} * 0.8)`,
|
||||
}}
|
||||
>
|
||||
P
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: textColor,
|
||||
fontWeight: 'bold',
|
||||
fontSize: sizes.fontSize,
|
||||
lineHeight: 1,
|
||||
fontFamily: 'Benzin-Bold, sans-serif',
|
||||
}}
|
||||
>
|
||||
{isLoading ? '...' : formatNumber(coins)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (showTooltip) {
|
||||
return (
|
||||
<CustomTooltip
|
||||
title={tooltipText}
|
||||
arrow
|
||||
placement="bottom"
|
||||
TransitionProps={{ timeout: 300 }}
|
||||
>
|
||||
{coinsDisplay}
|
||||
</CustomTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return coinsDisplay;
|
||||
}
|
||||
|
||||
// Примеры использования в комментариях для разработчика:
|
||||
/*
|
||||
// Пример 1: Простое отображение числа
|
||||
<CoinsDisplay value={1500} />
|
||||
|
||||
// Пример 2: Получение данных по username с автообновлением
|
||||
<CoinsDisplay
|
||||
username="player123"
|
||||
autoUpdate={true}
|
||||
updateInterval={30000} // обновлять каждые 30 секунд
|
||||
/>
|
||||
|
||||
// Пример 3: Кастомная стилизация без иконки
|
||||
<CoinsDisplay
|
||||
value={9999}
|
||||
size="small"
|
||||
showIcon={false}
|
||||
showTooltip={false}
|
||||
backgroundColor="rgba(255, 100, 100, 0.2)"
|
||||
textColor="#ffcc00"
|
||||
/>
|
||||
|
||||
// Пример 4: Большой отображение для профиля
|
||||
<CoinsDisplay
|
||||
username="player123"
|
||||
size="large"
|
||||
tooltipText="Ваш текущий баланс"
|
||||
iconColor="#00ffaa"
|
||||
/>
|
||||
*/
|
||||
75
src/renderer/components/CustomTooltip.tsx
Normal file
75
src/renderer/components/CustomTooltip.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Tooltip, { tooltipClasses, TooltipProps } from '@mui/material/Tooltip';
|
||||
|
||||
// Создаем кастомный стилизованный Tooltip с правильной типизацией
|
||||
const CustomTooltip = styled(({ className, ...props }: TooltipProps) => (
|
||||
<Tooltip {...props} classes={{ popper: className }} />
|
||||
))(({ theme }) => ({
|
||||
[`& .${tooltipClasses.tooltip}`]: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
color: '#fff',
|
||||
maxWidth: 300,
|
||||
fontSize: '0.9vw',
|
||||
border: '1px solid rgba(242, 113, 33, 0.5)',
|
||||
borderRadius: '1vw',
|
||||
padding: '1vw',
|
||||
boxShadow: `
|
||||
0 0 1.5vw rgba(242, 113, 33, 0.4),
|
||||
0 0 0.5vw rgba(233, 64, 87, 0.3),
|
||||
inset 0 0 0.5vw rgba(138, 35, 135, 0.2)
|
||||
`,
|
||||
fontFamily: 'Benzin-Bold',
|
||||
background: `
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(0, 0, 0, 0.95) 0%,
|
||||
rgba(20, 20, 20, 0.95) 100%
|
||||
)
|
||||
`,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: '1vw',
|
||||
padding: '2px',
|
||||
background: `
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(242, 113, 33, 0.8) 0%,
|
||||
rgba(233, 64, 87, 0.6) 50%,
|
||||
rgba(138, 35, 135, 0.4) 100%
|
||||
)
|
||||
`,
|
||||
WebkitMask: `
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0)
|
||||
`,
|
||||
WebkitMaskComposite: 'xor',
|
||||
maskComposite: 'exclude',
|
||||
zIndex: -1,
|
||||
},
|
||||
},
|
||||
[`& .${tooltipClasses.arrow}`]: {
|
||||
color: 'rgba(242, 113, 33, 0.9)',
|
||||
'&::before': {
|
||||
background: `
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(242, 113, 33, 0.9) 0%,
|
||||
rgba(233, 64, 87, 0.7) 50%,
|
||||
rgba(138, 35, 135, 0.5) 100%
|
||||
)
|
||||
`,
|
||||
border: '1px solid rgba(242, 113, 33, 0.5)',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export default CustomTooltip;
|
||||
205
src/renderer/components/FilesSelector.tsx
Normal file
205
src/renderer/components/FilesSelector.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children: FileNode[];
|
||||
}
|
||||
|
||||
interface FilesSelectorProps {
|
||||
packName: string;
|
||||
onSelectionChange: (selectedFiles: string[]) => void;
|
||||
initialSelected?: string[]; // Добавляем этот параметр
|
||||
}
|
||||
|
||||
export default function FilesSelector({
|
||||
packName,
|
||||
onSelectionChange,
|
||||
initialSelected = [], // Значение по умолчанию
|
||||
}: FilesSelectorProps) {
|
||||
const [files, setFiles] = useState<FileNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Используем initialSelected для начального состояния
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>(initialSelected);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFiles = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'get-pack-files',
|
||||
packName,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setFiles(result.files);
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка при загрузке файлов');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFiles();
|
||||
}, [packName]);
|
||||
|
||||
// Обработка выбора файла/папки
|
||||
const handleToggle = (
|
||||
path: string,
|
||||
isDirectory: boolean,
|
||||
children: FileNode[],
|
||||
) => {
|
||||
let newSelected = [...selectedFiles];
|
||||
|
||||
if (isDirectory) {
|
||||
if (selectedFiles.includes(path)) {
|
||||
// Если папка выбрана, убираем ее и все вложенные файлы
|
||||
newSelected = newSelected.filter((p) => !p.startsWith(path));
|
||||
} else {
|
||||
// Если папка не выбрана, добавляем ее и все вложенные файлы
|
||||
newSelected.push(path);
|
||||
const addChildPaths = (nodes: FileNode[]) => {
|
||||
for (const node of nodes) {
|
||||
newSelected.push(node.path);
|
||||
if (node.isDirectory) {
|
||||
addChildPaths(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
addChildPaths(children);
|
||||
}
|
||||
} else {
|
||||
// Для обычного файла просто переключаем состояние
|
||||
if (selectedFiles.includes(path)) {
|
||||
newSelected = newSelected.filter((p) => p !== path);
|
||||
} else {
|
||||
newSelected.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedFiles(newSelected);
|
||||
onSelectionChange(newSelected);
|
||||
};
|
||||
|
||||
// Переключение раскрытия папки
|
||||
const toggleFolder = (path: string) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
if (expandedFolders.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedFolders(newExpanded);
|
||||
};
|
||||
|
||||
// Рекурсивный компонент для отображения файлов и папок
|
||||
const renderFileTree = (nodes: FileNode[]) => {
|
||||
// Сортировка: сначала папки, потом файлы
|
||||
const sortedNodes = [...nodes].sort((a, b) => {
|
||||
// Если у элементов разные типы (папка/файл)
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1; // Папки идут первыми
|
||||
}
|
||||
// Если оба элемента одного типа, сортируем по алфавиту
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return (
|
||||
<List dense>
|
||||
{sortedNodes.map((node) => (
|
||||
<div key={node.path}>
|
||||
<ListItem
|
||||
sx={{
|
||||
borderRadius: '3vw',
|
||||
backgroundColor: '#FFFFFF1A',
|
||||
marginBottom: '1vh',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Checkbox
|
||||
edge="start"
|
||||
checked={selectedFiles.includes(node.path)}
|
||||
onChange={() =>
|
||||
handleToggle(node.path, node.isDirectory, node.children)
|
||||
}
|
||||
tabIndex={-1}
|
||||
sx={{ color: 'white' }}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
|
||||
{node.isDirectory && (
|
||||
<ListItemIcon onClick={() => toggleFolder(node.path)}>
|
||||
{expandedFolders.has(node.path) ? (
|
||||
<ExpandLessIcon sx={{ color: 'white' }} />
|
||||
) : (
|
||||
<ExpandMoreIcon sx={{ color: 'white' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
)}
|
||||
|
||||
<ListItemIcon>
|
||||
{node.isDirectory ? (
|
||||
<FolderIcon sx={{ color: 'white' }} />
|
||||
) : (
|
||||
<InsertDriveFileIcon sx={{ color: 'white' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
|
||||
<ListItemText
|
||||
primary={node.name}
|
||||
sx={{ color: 'white', fontFamily: 'Benzin-Bold' }}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{node.isDirectory && node.children.length > 0 && (
|
||||
<Collapse
|
||||
in={expandedFolders.has(node.path)}
|
||||
timeout="auto"
|
||||
unmountOnExit
|
||||
>
|
||||
<Box sx={{ pl: 4 }}>{renderFileTree(node.children)}</Box>
|
||||
</Collapse>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <FullScreenLoader fullScreen={false} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Typography color="error">{error}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxHeight: '300px', overflow: 'auto' }}>
|
||||
{renderFileTree(files)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
72
src/renderer/components/FullScreenLoader.tsx
Normal file
72
src/renderer/components/FullScreenLoader.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
interface FullScreenLoaderProps {
|
||||
message?: string;
|
||||
fullScreen?: boolean; // <-- новый проп
|
||||
}
|
||||
|
||||
export const FullScreenLoader = ({
|
||||
message,
|
||||
fullScreen = true,
|
||||
}: FullScreenLoaderProps) => {
|
||||
const containerSx = fullScreen
|
||||
? {
|
||||
position: 'fixed' as const,
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 3,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none' as const,
|
||||
}
|
||||
: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 3,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={containerSx}>
|
||||
{/* Градиентное вращающееся кольцо */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: 'conic-gradient(#F27121, #E940CD, #8A2387, #F27121)',
|
||||
animation: 'spin 1s linear infinite',
|
||||
WebkitMask: 'radial-gradient(circle, transparent 55%, black 56%)',
|
||||
mask: 'radial-gradient(circle, transparent 55%, black 56%)',
|
||||
'@keyframes spin': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{message && (
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
90
src/renderer/components/GradientTextField.tsx
Normal file
90
src/renderer/components/GradientTextField.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
// GradientTextField.tsx
|
||||
import React from 'react';
|
||||
import TextField, { TextFieldProps } from '@mui/material/TextField';
|
||||
|
||||
const GRADIENT =
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||
|
||||
const GradientTextField: React.FC<TextFieldProps> = ({ sx, ...props }) => {
|
||||
return (
|
||||
<TextField
|
||||
{...props}
|
||||
variant={props.variant ?? 'outlined'}
|
||||
sx={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
mt: '1.5vw',
|
||||
mb: '1.5vw',
|
||||
|
||||
// Рамка инпута
|
||||
'& .MuiOutlinedInput-root': {
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
background: 'transparent',
|
||||
borderRadius: '3.5vw',
|
||||
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
'& fieldset': {
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
},
|
||||
|
||||
// Градиентная рамка через псевдоэлемент
|
||||
'& .MuiOutlinedInput-root::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
padding: '0.4vw', // толщина рамки
|
||||
borderRadius: '3.5vw',
|
||||
background: GRADIENT,
|
||||
WebkitMask:
|
||||
'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
||||
WebkitMaskComposite: 'xor',
|
||||
maskComposite: 'exclude',
|
||||
zIndex: 0,
|
||||
},
|
||||
|
||||
// Вводимый текст
|
||||
'& .MuiInputBase-input': {
|
||||
color: 'white',
|
||||
padding: '1rem 1.5rem 1.1rem',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
},
|
||||
|
||||
// Лейбл как плейсхолдер, который уезжает вверх
|
||||
'& .MuiInputLabel-root': {
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '0.95rem',
|
||||
background: 'black',
|
||||
|
||||
// позиция "по умолчанию" — внутри инпута
|
||||
transform: 'translate(1.5rem, 1.1rem) scale(1)',
|
||||
|
||||
// градиентный текст
|
||||
color: 'transparent',
|
||||
backgroundImage: GRADIENT,
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
|
||||
// когда лейбл "съежился" (есть фокус или значение)
|
||||
'&.MuiInputLabel-shrink': {
|
||||
transform: 'translate(1.5rem, -1.3rem) scale(0.75)',
|
||||
},
|
||||
|
||||
'&.Mui-focused': {
|
||||
color: 'transparent', // не даём MUI перекрашивать
|
||||
},
|
||||
},
|
||||
|
||||
...(sx as object),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GradientTextField;
|
||||
72
src/renderer/components/HeadAvatar.tsx
Normal file
72
src/renderer/components/HeadAvatar.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
// src/renderer/components/HeadAvatar.tsx
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface HeadAvatarProps {
|
||||
skinUrl?: string;
|
||||
size?: number; // финальный размер головы, px
|
||||
}
|
||||
|
||||
export const HeadAvatar: React.FC<HeadAvatarProps> = ({
|
||||
skinUrl,
|
||||
size = 24,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!skinUrl || !canvasRef.current) return;
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous'; // на всякий случай, если CDN
|
||||
img.src = skinUrl;
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
// Координаты головы в стандартном скине 64x64:
|
||||
// База головы: (8, 8, 8, 8)
|
||||
// Слой шляпы/маски: (40, 8, 8, 8)
|
||||
|
||||
// Рисуем основную голову
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(
|
||||
img,
|
||||
8, // sx
|
||||
8, // sy
|
||||
8, // sWidth
|
||||
8, // sHeight
|
||||
0, // dx
|
||||
0, // dy
|
||||
size, // dWidth
|
||||
size, // dHeight
|
||||
);
|
||||
|
||||
// Рисуем слой шляпы поверх (если есть)
|
||||
ctx.drawImage(img, 40, 8, 8, 8, 0, 0, size, size);
|
||||
};
|
||||
|
||||
img.onerror = (e) => {
|
||||
console.error('Не удалось загрузить скин для HeadAvatar:', e);
|
||||
};
|
||||
}, [skinUrl, size]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 4,
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
61
src/renderer/components/Login/AuthForm.tsx
Normal file
61
src/renderer/components/Login/AuthForm.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { Box, Button, TextField, Typography } from '@mui/material';
|
||||
import GradientTextField from '../../components/GradientTextField';
|
||||
|
||||
interface AuthFormProps {
|
||||
config: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onLogin: () => void;
|
||||
}
|
||||
|
||||
const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.5vw',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<GradientTextField
|
||||
label="Никнейм"
|
||||
required
|
||||
name="username"
|
||||
value={config.username}
|
||||
onChange={handleInputChange}
|
||||
sx={{
|
||||
mt: '2.5vw',
|
||||
mb: '0vw'
|
||||
}}
|
||||
/>
|
||||
<GradientTextField
|
||||
label="Пароль"
|
||||
required
|
||||
name="password"
|
||||
value={config.password}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<Button onClick={onLogin} variant="contained"
|
||||
sx={{
|
||||
transition: 'transform 0.3s ease',
|
||||
width: '60%',
|
||||
mt: 2,
|
||||
background: 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderRadius: '2.5vw',
|
||||
fontSize: '2vw',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
|
||||
},
|
||||
}}>
|
||||
Войти
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthForm;
|
||||
26
src/renderer/components/Login/MemorySlider.tsx
Normal file
26
src/renderer/components/Login/MemorySlider.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Slider } from '@mui/material';
|
||||
|
||||
interface MemorySliderProps {
|
||||
memory: number;
|
||||
onChange: (e: Event, value: number | number[]) => void;
|
||||
}
|
||||
|
||||
const MemorySlider = ({ memory, onChange }: MemorySliderProps) => {
|
||||
return (
|
||||
<Slider
|
||||
name="memory"
|
||||
aria-label="Memory"
|
||||
defaultValue={4096}
|
||||
valueLabelDisplay="auto"
|
||||
shiftStep={1024}
|
||||
step={1024}
|
||||
marks
|
||||
min={1024}
|
||||
max={32628}
|
||||
value={memory}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemorySlider;
|
||||
69
src/renderer/components/MarkdownEditor.tsx
Normal file
69
src/renderer/components/MarkdownEditor.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
// components/MarkdownEditor.tsx
|
||||
import { useEffect, useRef } from 'react';
|
||||
import EasyMDE from 'easymde';
|
||||
import 'easymde/dist/easymde.min.css';
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const MarkdownEditor = ({ value, onChange }: MarkdownEditorProps) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const editorRef = useRef<EasyMDE | null>(null);
|
||||
|
||||
// Один раз создаём EasyMDE поверх textarea
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) return;
|
||||
if (editorRef.current) return; // уже создан
|
||||
|
||||
const instance = new EasyMDE({
|
||||
element: textareaRef.current,
|
||||
initialValue: value,
|
||||
spellChecker: false,
|
||||
minHeight: '200px',
|
||||
toolbar: [
|
||||
'bold',
|
||||
'italic',
|
||||
'strikethrough',
|
||||
'|',
|
||||
'heading',
|
||||
'quote',
|
||||
'unordered-list',
|
||||
'ordered-list',
|
||||
'|',
|
||||
'link',
|
||||
'image',
|
||||
'|',
|
||||
'preview',
|
||||
'side-by-side',
|
||||
'fullscreen',
|
||||
'|',
|
||||
'guide',
|
||||
],
|
||||
status: false,
|
||||
});
|
||||
|
||||
instance.codemirror.on('change', () => {
|
||||
onChange(instance.value());
|
||||
});
|
||||
|
||||
editorRef.current = instance;
|
||||
|
||||
// При анмаунте красиво убрать за собой
|
||||
return () => {
|
||||
instance.toTextArea();
|
||||
editorRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Если извне поменяли value — обновляем редактор
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.value() !== value) {
|
||||
editorRef.current.value(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Сам текстариа — просто якорь для EasyMDE
|
||||
return <textarea ref={textareaRef} />;
|
||||
};
|
||||
110
src/renderer/components/MinecraftBackground.tsx
Normal file
110
src/renderer/components/MinecraftBackground.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { Box } from '@mui/material';
|
||||
import heart from '../../../assets/images/heart.svg';
|
||||
|
||||
export default function MinecraftBackground() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: 0.25,
|
||||
overflow: 'hidden',
|
||||
zIndex: -10,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
gap: '1vw',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
rotate: '-20deg',
|
||||
paddingTop: '30vw',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={heart}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '20vw',
|
||||
height: '20vw',
|
||||
rotate: '-20deg',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src={heart}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '20vw',
|
||||
height: '20vw',
|
||||
paddingBottom: '5vw',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src={heart}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '20vw',
|
||||
height: '20vw',
|
||||
rotate: '20deg',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
gap: '1vw',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
rotate: '160deg',
|
||||
paddingTop: '80vw',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={heart}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '20vw',
|
||||
height: '20vw',
|
||||
rotate: '-20deg',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src={heart}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '20vw',
|
||||
height: '20vw',
|
||||
paddingBottom: '5vw',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src={heart}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '20vw',
|
||||
height: '20vw',
|
||||
rotate: '20deg',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
63
src/renderer/components/Notifier.tsx
Normal file
63
src/renderer/components/Notifier.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { Alert, Box, Snackbar, Button } from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const Notifier = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [severity, setSeverity] = useState<
|
||||
'error' | 'warning' | 'info' | 'success'
|
||||
>('info');
|
||||
const [hasUpdateAvailable, setHasUpdateAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Слушаем событие о наличии обновления
|
||||
window.electron.ipcRenderer.on('update-available', () => {
|
||||
setMessage('Доступно новое обновление');
|
||||
setSeverity('info');
|
||||
setHasUpdateAvailable(true);
|
||||
setOpen(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Отписываемся от события при размонтировании
|
||||
window.electron.ipcRenderer.removeAllListeners('update-available');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
window.electron.ipcRenderer.invoke('install-update');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={hasUpdateAvailable ? null : 6000}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<Alert
|
||||
severity={severity}
|
||||
action={
|
||||
hasUpdateAvailable && (
|
||||
<>
|
||||
<Button color="primary" size="small" onClick={handleUpdate}>
|
||||
Обновить сейчас
|
||||
</Button>
|
||||
<Button color="secondary" size="small" onClick={handleClose}>
|
||||
Позже
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
281
src/renderer/components/OnlinePlayersPanel.tsx
Normal file
281
src/renderer/components/OnlinePlayersPanel.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
// src/renderer/components/OnlinePlayersPanel.tsx
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Chip,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Select,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
fetchActiveServers,
|
||||
fetchOnlinePlayers,
|
||||
fetchPlayer,
|
||||
Server,
|
||||
} from '../api';
|
||||
import { FullScreenLoader } from './FullScreenLoader';
|
||||
import { HeadAvatar } from './HeadAvatar';
|
||||
import { translateServer } from '../utils/serverTranslator';
|
||||
|
||||
type OnlinePlayerFlat = {
|
||||
username: string;
|
||||
uuid: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
onlineSince: string;
|
||||
};
|
||||
|
||||
interface OnlinePlayersPanelProps {
|
||||
currentUsername: string;
|
||||
}
|
||||
|
||||
export const OnlinePlayersPanel: React.FC<OnlinePlayersPanelProps> = ({
|
||||
currentUsername,
|
||||
}) => {
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [onlinePlayers, setOnlinePlayers] = useState<OnlinePlayerFlat[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [serverFilter, setServerFilter] = useState<string>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const [skinMap, setSkinMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const activeServers = await fetchActiveServers();
|
||||
setServers(activeServers);
|
||||
|
||||
const results = await Promise.all(
|
||||
activeServers.map((s) => fetchOnlinePlayers(s.id)),
|
||||
);
|
||||
|
||||
const flat: OnlinePlayerFlat[] = [];
|
||||
results.forEach((res) => {
|
||||
res.online_players.forEach((p) => {
|
||||
flat.push({
|
||||
username: p.username,
|
||||
uuid: p.uuid,
|
||||
serverId: res.server.id,
|
||||
serverName: res.server.name,
|
||||
onlineSince: p.online_since,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setOnlinePlayers(flat);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Не удалось загрузить онлайн игроков');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, []);
|
||||
|
||||
// Догружаем скины по uuid
|
||||
useEffect(() => {
|
||||
const loadSkins = async () => {
|
||||
// Берём всех видимых игроков (чтобы не грузить для тысяч, если их много)
|
||||
const uuids = Array.from(new Set(onlinePlayers.map((p) => p.uuid)));
|
||||
|
||||
const toLoad = uuids.filter((uuid) => !skinMap[uuid]);
|
||||
if (!toLoad.length) return;
|
||||
|
||||
try {
|
||||
// Просто по очереди, чтобы не DDOS'ить API
|
||||
for (const uuid of toLoad) {
|
||||
try {
|
||||
const player = await fetchPlayer(uuid);
|
||||
if (player.skin_url) {
|
||||
setSkinMap((prev) => ({
|
||||
...prev,
|
||||
[uuid]: player.skin_url,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Не удалось получить скин для', uuid, e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка при загрузке скинов:', e);
|
||||
}
|
||||
};
|
||||
|
||||
loadSkins();
|
||||
}, [onlinePlayers]);
|
||||
|
||||
const filteredPlayers = useMemo(() => {
|
||||
return (
|
||||
onlinePlayers
|
||||
.filter((p) =>
|
||||
serverFilter === 'all' ? true : p.serverId === serverFilter,
|
||||
)
|
||||
.filter((p) =>
|
||||
search.trim()
|
||||
? p.username.toLowerCase().includes(search.toLowerCase())
|
||||
: true,
|
||||
)
|
||||
// свой ник наверх
|
||||
.sort((a, b) => {
|
||||
if (a.username === currentUsername && b.username !== currentUsername)
|
||||
return -1;
|
||||
if (b.username === currentUsername && a.username !== currentUsername)
|
||||
return 1;
|
||||
return a.username.localeCompare(b.username);
|
||||
})
|
||||
);
|
||||
}, [onlinePlayers, serverFilter, search, currentUsername]);
|
||||
|
||||
if (loading) {
|
||||
return <FullScreenLoader message="Загружаем игроков онлайн..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Typography color="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
if (!onlinePlayers.length) {
|
||||
return (
|
||||
<Typography sx={{ mt: 2, opacity: 0.8 }}>
|
||||
Сейчас на серверах никого нет.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const totalOnline = onlinePlayers.length;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
mt: 3,
|
||||
p: 2,
|
||||
borderRadius: '1vw',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
color: 'white',
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1.2vw',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
Игроки онлайн
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.9vw', opacity: 0.7 }}>
|
||||
Сейчас на серверах: {totalOnline}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel sx={{ color: 'white' }}>Сервер</InputLabel>
|
||||
<Select
|
||||
label="Сервер"
|
||||
value={serverFilter}
|
||||
onChange={(e) => setServerFilter(e.target.value)}
|
||||
sx={{ color: 'white' }}
|
||||
>
|
||||
<MenuItem value="all" sx={{ color: 'black' }}>
|
||||
Все сервера
|
||||
</MenuItem>
|
||||
{servers.map((s) => (
|
||||
<MenuItem key={s.id} value={s.id} sx={{ color: 'black' }}>
|
||||
{s.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Поиск по нику"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ color: 'white' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: '35vh',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{filteredPlayers.map((p) => (
|
||||
<Box
|
||||
key={p.uuid}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
py: 0.6,
|
||||
px: 1.5,
|
||||
borderRadius: '999px',
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<HeadAvatar skinUrl={skinMap[p.uuid]} size={24} />
|
||||
<Typography sx={{ fontFamily: 'Benzin-Bold' }}>
|
||||
{p.username}
|
||||
</Typography>
|
||||
{p.username === currentUsername && (
|
||||
<Chip
|
||||
label="Вы"
|
||||
size="small"
|
||||
sx={{
|
||||
height: '1.4rem',
|
||||
fontSize: '0.7rem',
|
||||
bgcolor: 'rgb(255,77,77)',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Chip
|
||||
label={translateServer({ name: p.serverName })}
|
||||
size="small"
|
||||
sx={{ bgcolor: 'rgba(255,255,255,0.08)', color: 'white' }}
|
||||
/>
|
||||
{/* Можно позже красиво форматировать onlineSince */}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
389
src/renderer/components/PlayerInventory.tsx
Normal file
389
src/renderer/components/PlayerInventory.tsx
Normal file
@ -0,0 +1,389 @@
|
||||
// src/renderer/components/PlayerInventory.tsx
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
RequestPlayerInventory,
|
||||
getPlayerInventory,
|
||||
sellItem,
|
||||
PlayerInventoryItem,
|
||||
} from '../api';
|
||||
import { FullScreenLoader } from './FullScreenLoader';
|
||||
|
||||
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: '1vw' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: '1vw',
|
||||
alignItems: 'center',
|
||||
mb: '2vw',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" color="white">
|
||||
Ваш инвентарь
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={fetchPlayerInventory}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
borderRadius: '20px',
|
||||
p: '10px 25px',
|
||||
color: 'white',
|
||||
borderColor: 'rgba(255, 77, 77, 1)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 77, 77, 1)',
|
||||
borderColor: 'rgba(255, 77, 77, 1)',
|
||||
},
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1vw',
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<FullScreenLoader fullScreen={false} />
|
||||
) : (
|
||||
<>
|
||||
{inventoryItems.length === 0 ? (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="white"
|
||||
sx={{ textAlign: 'center', my: 4 }}
|
||||
>
|
||||
Ваш инвентарь пуст или не удалось загрузить предметы.
|
||||
</Typography>
|
||||
) : (
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
columns={10}
|
||||
sx={{ justifyContent: 'center' }}
|
||||
>
|
||||
{inventoryItems.map((item) =>
|
||||
item.material !== 'AIR' && item.amount > 0 ? (
|
||||
<Grid item xs={1} key={item.slot}>
|
||||
<Card
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': { transform: 'scale(1.03)' },
|
||||
borderRadius: '1vw',
|
||||
}}
|
||||
onClick={() => handleOpenSellDialog(item)}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
sx={{
|
||||
minWidth: '10vw',
|
||||
minHeight: '10vw',
|
||||
maxHeight: '10vw',
|
||||
objectFit: 'contain',
|
||||
p: '1vw',
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
image={`https://cdn.minecraft.popa-popa.ru/textures/${item.material.toLowerCase()}.png`}
|
||||
alt={item.material}
|
||||
/>
|
||||
<CardContent sx={{ p: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: '1vw',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="white"
|
||||
noWrap
|
||||
sx={{ fontSize: '0.8vw' }}
|
||||
>
|
||||
{getItemDisplayName(item.material)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="white"
|
||||
sx={{ fontSize: '0.8vw' }}
|
||||
>
|
||||
{item.amount > 1 ? `x${item.amount}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
{Object.keys(item.enchants || {}).length > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="secondary"
|
||||
sx={{ display: 'block', fontSize: '0.8vw' }}
|
||||
>
|
||||
Зачарования: {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={`https://cdn.minecraft.popa-popa.ru/textures/${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 ? <FullScreenLoader fullScreen={false} /> : 'Продать'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
79
src/renderer/components/PlayerPreviewModal.tsx
Normal file
79
src/renderer/components/PlayerPreviewModal.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
// src/renderer/components/CapePreviewModal.tsx
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
IconButton,
|
||||
Box,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import SkinViewer from './SkinViewer';
|
||||
|
||||
interface CapePreviewModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
capeUrl: string;
|
||||
skinUrl?: string;
|
||||
}
|
||||
|
||||
const CapePreviewModal: React.FC<CapePreviewModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
capeUrl,
|
||||
skinUrl,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogContent
|
||||
sx={{
|
||||
bgcolor: 'rgba(5, 5, 15, 0.96)',
|
||||
position: 'relative',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'white',
|
||||
fontSize: '1.1rem',
|
||||
}}
|
||||
>
|
||||
Предпросмотр плаща
|
||||
</Typography>
|
||||
|
||||
<SkinViewer
|
||||
width={350}
|
||||
height={450}
|
||||
capeUrl={capeUrl} // скин возьмётся дефолтный из SkinViewer
|
||||
skinUrl={skinUrl}
|
||||
autoRotate={true}
|
||||
walkingSpeed={0.5}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CapePreviewModal;
|
||||
118
src/renderer/components/ServerStatus/ServerStatus.tsx
Normal file
118
src/renderer/components/ServerStatus/ServerStatus.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { Box, Typography, Avatar } from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ServerStatusProps {
|
||||
serverIp: string;
|
||||
serverPort?: number;
|
||||
refreshInterval?: number; // Интервал обновления в миллисекундах
|
||||
}
|
||||
|
||||
const ServerStatus = ({
|
||||
serverIp,
|
||||
serverPort,
|
||||
refreshInterval = 60000, // По умолчанию обновление раз в минуту
|
||||
}: ServerStatusProps) => {
|
||||
const [serverStatus, setServerStatus] = useState<{
|
||||
online: number;
|
||||
max: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
icon: string | null;
|
||||
motd: string;
|
||||
}>({
|
||||
online: 0,
|
||||
max: 0,
|
||||
loading: true,
|
||||
error: null,
|
||||
icon: null,
|
||||
motd: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Функция для получения статуса сервера
|
||||
const fetchServerStatus = async () => {
|
||||
try {
|
||||
setServerStatus((prev) => ({ ...prev, loading: true, error: null }));
|
||||
console.log('Отправляем запрос на сервер с параметрами:', {
|
||||
host: serverIp,
|
||||
port: serverPort || 25565,
|
||||
});
|
||||
|
||||
// Проверяем, что serverIp имеет значение
|
||||
if (!serverIp) {
|
||||
throw new Error('Адрес сервера не указан');
|
||||
}
|
||||
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'get-server-status',
|
||||
{
|
||||
host: serverIp,
|
||||
port: serverPort || 25565,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setServerStatus({
|
||||
online: result.online,
|
||||
max: result.max,
|
||||
loading: false,
|
||||
error: null,
|
||||
icon: result.icon,
|
||||
motd: result.motd || serverIp,
|
||||
});
|
||||
} else {
|
||||
setServerStatus({
|
||||
online: 0,
|
||||
max: 0,
|
||||
loading: false,
|
||||
error: result.error || 'Неизвестная ошибка',
|
||||
icon: null,
|
||||
motd: '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении статуса сервера:', error);
|
||||
setServerStatus((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: 'Ошибка при получении статуса сервера',
|
||||
icon: null,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузка при первом рендере
|
||||
fetchServerStatus();
|
||||
|
||||
// Периодическое обновление
|
||||
const interval = setInterval(fetchServerStatus, refreshInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [serverIp, serverPort, refreshInterval]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* Отображаем иконку сервера или иконку по умолчанию */}
|
||||
{serverStatus.icon ? (
|
||||
<Avatar
|
||||
src={serverStatus.icon}
|
||||
alt={serverStatus.motd || 'Minecraft сервер'}
|
||||
sx={{ width: '2em', height: '2em' }}
|
||||
/>
|
||||
) : (
|
||||
<Avatar sx={{ width: '2em', height: '2em', bgcolor: 'primary.main' }}>
|
||||
?
|
||||
</Avatar>
|
||||
)}
|
||||
{serverStatus.error ? (
|
||||
<Typography color="error">Ошибка загрузки</Typography>
|
||||
) : (
|
||||
<Typography sx={{ fontWeight: 'bold' }}>
|
||||
{serverStatus.online} / {serverStatus.max} игроков
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerStatus;
|
||||
92
src/renderer/components/Settings/SettingsModal.tsx
Normal file
92
src/renderer/components/Settings/SettingsModal.tsx
Normal 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;
|
||||
240
src/renderer/components/ShopItem.tsx
Normal file
240
src/renderer/components/ShopItem.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
// src/renderer/components/ShopItem.tsx
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import CoinsDisplay from './CoinsDisplay';
|
||||
import { CapePreview } from './CapePreview';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import CapePreviewModal from './PlayerPreviewModal';
|
||||
|
||||
export type ShopItemType = 'case' | 'cape';
|
||||
|
||||
export interface ShopItemProps {
|
||||
type: ShopItemType;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
price?: number;
|
||||
itemsCount?: number;
|
||||
isOpening?: boolean;
|
||||
playerSkinUrl?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function ShopItem({
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
imageUrl,
|
||||
price,
|
||||
itemsCount,
|
||||
isOpening,
|
||||
disabled,
|
||||
playerSkinUrl,
|
||||
onClick,
|
||||
}: ShopItemProps) {
|
||||
const buttonText =
|
||||
type === 'case' ? (isOpening ? 'Открываем...' : 'Открыть кейс') : 'Купить';
|
||||
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: 300,
|
||||
height: 440,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
background: 'rgba(20,20,20,0.9)',
|
||||
borderRadius: '2.5vw',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
boxShadow: '0 10px 40px rgba(0,0,0,0.8)',
|
||||
overflow: 'hidden',
|
||||
|
||||
transition: 'transform 0.35s ease, box-shadow 0.35s ease',
|
||||
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 20px 60px rgba(242,113,33,0.45)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Градиентный свет сверху */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
background:
|
||||
'radial-gradient(circle at top, rgba(242,113,33,0.25), transparent 60%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{imageUrl && (
|
||||
<Box sx={{ position: 'relative', p: 1.5, pb: 0 }}>
|
||||
{type === 'case' ? (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '1.8vw',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(40,40,40,0.9), rgba(15,15,15,0.9))',
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={imageUrl}
|
||||
alt={name}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 160,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<CapePreview imageUrl={imageUrl} alt={name} />
|
||||
)}
|
||||
|
||||
{/* Кнопка предпросмотра плаща */}
|
||||
{type === 'cape' && (
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
color: 'white',
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<CardContent
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
pt: 2,
|
||||
pb: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1.05rem',
|
||||
mb: 1,
|
||||
backgroundImage:
|
||||
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
|
||||
{description && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
fontSize: '0.85rem',
|
||||
minHeight: 42,
|
||||
maxHeight: 42,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{typeof price === 'number' && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mt: 1.2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.8rem' }}
|
||||
>
|
||||
Цена
|
||||
</Typography>
|
||||
<CoinsDisplay value={price} size="small" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{type === 'case' && typeof itemsCount === 'number' && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
fontSize: '0.75rem',
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
Предметов в кейсе: {itemsCount}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Кнопка как в Registration */}
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
mt: 2,
|
||||
transition: 'transform 0.3s ease',
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderRadius: '2.5vw',
|
||||
fontSize: '0.85rem',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
{type === 'cape' && imageUrl && (
|
||||
<CapePreviewModal
|
||||
open={previewOpen}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
capeUrl={imageUrl}
|
||||
skinUrl={playerSkinUrl}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
75
src/renderer/components/SkinViewer.tsx
Normal file
75
src/renderer/components/SkinViewer.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
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 ||
|
||||
'https://static.planetminecraft.com/files/resource_media/skin/original-steve-15053860.png',
|
||||
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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
466
src/renderer/components/TopBar.tsx
Normal file
466
src/renderer/components/TopBar.tsx
Normal file
@ -0,0 +1,466 @@
|
||||
import { Box, Button, Tab, Tabs, Typography } from '@mui/material';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import { fetchCoins } from '../api';
|
||||
import CustomTooltip from './CustomTooltip';
|
||||
import CoinsDisplay from './CoinsDisplay';
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: {
|
||||
ipcRenderer: {
|
||||
invoke(channel: string, ...args: unknown[]): Promise<any>;
|
||||
on(channel: string, func: (...args: unknown[]) => void): void;
|
||||
removeAllListeners(channel: string): void;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Определяем пропсы
|
||||
interface TopBarProps {
|
||||
onRegister?: () => void; // Опционально, если нужен обработчик регистрации
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export default function TopBar({ onRegister, username }: TopBarProps) {
|
||||
// Получаем текущий путь
|
||||
const location = useLocation();
|
||||
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(1);
|
||||
const [activePage, setActivePage] = useState('versions');
|
||||
const tabsWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setValue(newValue);
|
||||
if (newValue === 0) {
|
||||
navigate('/news');
|
||||
} else if (newValue === 1) {
|
||||
navigate('/');
|
||||
} else if (newValue === 2) {
|
||||
navigate('/profile');
|
||||
} else if (newValue === 3) {
|
||||
navigate('/shop');
|
||||
} else if (newValue === 4) {
|
||||
navigate('/marketplace');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/news') {
|
||||
setValue(0);
|
||||
setActivePage('news');
|
||||
} else if (location.pathname === '/') {
|
||||
setValue(1);
|
||||
setActivePage('versions');
|
||||
} else if (location.pathname.startsWith('/profile')) {
|
||||
setValue(2);
|
||||
setActivePage('profile');
|
||||
} else if (location.pathname.startsWith('/shop')) {
|
||||
setValue(3);
|
||||
setActivePage('shop');
|
||||
} else if (location.pathname.startsWith('/marketplace')) {
|
||||
setValue(4);
|
||||
setActivePage('marketplace');
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLaunchPage = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleTabsWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||
// чтобы страница не скроллилась вертикально
|
||||
event.preventDefault();
|
||||
|
||||
if (!tabsWrapperRef.current) return;
|
||||
|
||||
// Находим внутренний скроллер MUI Tabs
|
||||
const scroller = tabsWrapperRef.current.querySelector(
|
||||
'.MuiTabs-scroller',
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
if (!scroller) return;
|
||||
|
||||
// Прокручиваем горизонтально, используя вертикальный скролл мыши
|
||||
scroller.scrollLeft += event.deltaY * 0.3;
|
||||
};
|
||||
|
||||
// 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]);
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('launcher_config');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '7vh',
|
||||
zIndex: 1000,
|
||||
width: '100%',
|
||||
WebkitAppRegion: 'drag',
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
// marginLeft: '1em',
|
||||
// marginRight: '1em',
|
||||
}}
|
||||
>
|
||||
{/* Левая часть */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
gap: '2vw',
|
||||
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',
|
||||
transition: 'transform 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.2)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ArrowBackRoundedIcon />
|
||||
</Button>
|
||||
)}
|
||||
{!isLaunchPage && !isRegistrationPage && !isLoginPage && (
|
||||
<Box
|
||||
ref={tabsWrapperRef}
|
||||
onWheel={handleTabsWheel}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'transparent',
|
||||
'& .MuiTabs-indicator': {
|
||||
backgroundColor: 'rgba(255, 77, 77, 1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CustomTooltip
|
||||
title={
|
||||
'Покрути колесиком мыши чтобы увидеть остальные элементы меню'
|
||||
}
|
||||
arrow
|
||||
placement="bottom"
|
||||
TransitionProps={{ timeout: 100 }}
|
||||
>
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-label="basic tabs example"
|
||||
variant="scrollable"
|
||||
scrollButtons={false}
|
||||
disableRipple={true}
|
||||
sx={{ maxWidth: '42vw' }}
|
||||
>
|
||||
<Tab
|
||||
label="Новости"
|
||||
disableRipple={true}
|
||||
onClick={() => {
|
||||
setActivePage('news');
|
||||
}}
|
||||
sx={{
|
||||
color: 'white',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '0.7em',
|
||||
'&.Mui-selected': {
|
||||
color: 'rgba(255, 77, 77, 1)',
|
||||
},
|
||||
'&:hover': {
|
||||
color: 'rgb(177, 52, 52)',
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
<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(177, 52, 52)',
|
||||
},
|
||||
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(177, 52, 52)',
|
||||
},
|
||||
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(177, 52, 52)',
|
||||
},
|
||||
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(177, 52, 52)',
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
</CustomTooltip>
|
||||
</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',
|
||||
}}
|
||||
>
|
||||
{!isLoginPage && !isRegistrationPage && username && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => logout()}
|
||||
sx={{
|
||||
width: '8em',
|
||||
height: '3em',
|
||||
borderRadius: '2.5vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '0.9em',
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
transition: 'transform 0.3s ease',
|
||||
'&:hover': {
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 4px 15px rgba(242, 113, 33, 0.4)',
|
||||
},
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</Button>
|
||||
)}
|
||||
{/* Кнопка регистрации, если на странице логина */}
|
||||
{!isLoginPage && !isRegistrationPage && username && (
|
||||
<CoinsDisplay
|
||||
username={username}
|
||||
size="medium"
|
||||
autoUpdate={true}
|
||||
showTooltip={true}
|
||||
/>
|
||||
)}
|
||||
{isLoginPage && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate('/registration')}
|
||||
sx={{
|
||||
width: '13em',
|
||||
height: '3em',
|
||||
borderRadius: '2.5vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '0.9em',
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
transition: 'transform 0.3s ease',
|
||||
'&:hover': {
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 4px 15px rgba(242, 113, 33, 0.4)',
|
||||
},
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
Регистрация
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Кнопки управления окном */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.electron.ipcRenderer.invoke('minimize-app');
|
||||
}}
|
||||
sx={{
|
||||
minWidth: 'unset',
|
||||
minHeight: 'unset',
|
||||
width: '3em',
|
||||
height: '3em',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<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
|
||||
onClick={() => {
|
||||
window.electron.ipcRenderer.invoke('close-app');
|
||||
}}
|
||||
sx={{
|
||||
minWidth: 'unset',
|
||||
minHeight: 'unset',
|
||||
width: '3em',
|
||||
height: '3em',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<CloseRoundedIcon sx={{ color: 'white' }} />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
22
src/renderer/components/popa-popa.tsx
Normal file
22
src/renderer/components/popa-popa.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export default function PopaPopa() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Typography variant="h3">POPA</Typography>
|
||||
<Typography variant="h3">-</Typography>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
background: '-webkit-linear-gradient(200.96deg, #88BCFF, #FD71FF)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
POPA
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
94
src/renderer/hooks/useAuth.ts
Normal file
94
src/renderer/hooks/useAuth.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface AuthSession {
|
||||
accessToken: string;
|
||||
clientToken: string;
|
||||
selectedProfile: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function useAuth() {
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
// Проверка валидности токена
|
||||
const validateSession = async (accessToken: string): Promise<boolean> => {
|
||||
try {
|
||||
setStatus('validating');
|
||||
const response = await window.electron.ipcRenderer.invoke(
|
||||
'validate-token',
|
||||
accessToken,
|
||||
);
|
||||
setStatus('idle');
|
||||
return response.valid;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при валидации токена:', error);
|
||||
setStatus('error');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Обновление токена
|
||||
const refreshSession = async (
|
||||
accessToken: string,
|
||||
clientToken: string,
|
||||
): Promise<AuthSession | null> => {
|
||||
try {
|
||||
setStatus('refreshing');
|
||||
const response = await window.electron.ipcRenderer.invoke(
|
||||
'refresh-token',
|
||||
{ accessToken, clientToken },
|
||||
);
|
||||
setStatus('idle');
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении токена:', error);
|
||||
setStatus('error');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Аутентификация в Ely.by
|
||||
const authenticateWithElyBy = async (
|
||||
username: string,
|
||||
password: string,
|
||||
saveConfigFunc: Function,
|
||||
): Promise<AuthSession | null> => {
|
||||
try {
|
||||
setStatus('authenticating');
|
||||
const response = await window.electron.ipcRenderer.invoke(
|
||||
'authenticate',
|
||||
{ username, password },
|
||||
);
|
||||
|
||||
if (response && response.accessToken) {
|
||||
// Правильно сохраняем данные в конфигурации
|
||||
saveConfigFunc({
|
||||
username: response.selectedProfile.name, // Имя игрока как строка
|
||||
uuid: response.selectedProfile.id,
|
||||
accessToken: response.accessToken,
|
||||
clientToken: response.clientToken,
|
||||
memory: 4096, // Сохраняем значение по умолчанию или из предыдущей конфигурации
|
||||
});
|
||||
|
||||
setStatus('authenticated');
|
||||
return response;
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при аутентификации:', error);
|
||||
setStatus('error');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
status,
|
||||
validateSession,
|
||||
refreshSession,
|
||||
authenticateWithElyBy,
|
||||
};
|
||||
}
|
||||
61
src/renderer/hooks/useConfig.ts
Normal file
61
src/renderer/hooks/useConfig.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Добавляем определение типа Config
|
||||
interface Config {
|
||||
username: string;
|
||||
password: string;
|
||||
memory: number;
|
||||
comfortVersion: string;
|
||||
accessToken: string;
|
||||
clientToken: string;
|
||||
uuid?: string; // Добавляем uuid, который используется для авторизации
|
||||
}
|
||||
|
||||
const useConfig = () => {
|
||||
const [config, setConfig] = useState<Config>({
|
||||
username: '',
|
||||
password: '',
|
||||
memory: 4096,
|
||||
comfortVersion: '',
|
||||
accessToken: '',
|
||||
clientToken: '',
|
||||
});
|
||||
|
||||
const loadInitialConfig = () => {
|
||||
try {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
if (savedConfig) {
|
||||
return JSON.parse(savedConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Ошибка загрузки конфигурации:', error);
|
||||
}
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
comfortVersion: '',
|
||||
accessToken: '',
|
||||
clientToken: '',
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const savedConfig = loadInitialConfig();
|
||||
setConfig(savedConfig);
|
||||
}, []);
|
||||
|
||||
const saveConfig = (newConfig: Partial<Config>) => {
|
||||
const updatedConfig = { ...config, ...newConfig };
|
||||
setConfig(updatedConfig);
|
||||
localStorage.setItem('launcher_config', JSON.stringify(updatedConfig));
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setConfig((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
return { config, setConfig, saveConfig, handleInputChange };
|
||||
};
|
||||
|
||||
export default useConfig;
|
||||
@ -6,7 +6,7 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="script-src 'self' 'unsafe-inline'"
|
||||
/>
|
||||
<title>Hello Electron React!</title>
|
||||
<title>popa-launcher</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
550
src/renderer/pages/LaunchPage.tsx
Normal file
550
src/renderer/pages/LaunchPage.tsx
Normal file
@ -0,0 +1,550 @@
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Snackbar,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import ServerStatus from '../components/ServerStatus/ServerStatus';
|
||||
import PopaPopa from '../components/popa-popa';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import React from 'react';
|
||||
import SettingsModal from '../components/Settings/SettingsModal';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: {
|
||||
ipcRenderer: {
|
||||
invoke(channel: string, ...args: unknown[]): Promise<any>;
|
||||
on(channel: string, func: (...args: unknown[]) => void): void;
|
||||
removeAllListeners(channel: string): void;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Определяем тип для props
|
||||
interface LaunchPageProps {
|
||||
onLaunchPage?: () => void;
|
||||
launchOptions?: {
|
||||
// Делаем опциональным
|
||||
downloadUrl: string;
|
||||
apiReleaseUrl: string;
|
||||
versionFileName: string;
|
||||
packName: string;
|
||||
memory: number;
|
||||
baseVersion: string;
|
||||
serverIp: string;
|
||||
fabricVersion: string;
|
||||
};
|
||||
}
|
||||
|
||||
const LaunchPage = ({
|
||||
onLaunchPage,
|
||||
launchOptions = {} as any,
|
||||
}: LaunchPageProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { versionId } = useParams();
|
||||
const [versionConfig, setVersionConfig] = useState<any>(null);
|
||||
|
||||
// Начальное состояние должно быть пустым или с минимальными значениями
|
||||
const [config, setConfig] = useState<{
|
||||
memory: number;
|
||||
preserveFiles: string[];
|
||||
}>({
|
||||
memory: 0,
|
||||
preserveFiles: [],
|
||||
});
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [buffer, setBuffer] = useState(10);
|
||||
const [installStatus, setInstallStatus] = useState('');
|
||||
const [notification, setNotification] = useState<{
|
||||
open: boolean;
|
||||
message: string;
|
||||
severity: 'success' | 'error' | 'info';
|
||||
}>({ open: false, message: '', severity: 'info' });
|
||||
const [installStep, setInstallStep] = useState('');
|
||||
const [installMessage, setInstallMessage] = useState('');
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [isGameRunning, setIsGameRunning] = useState(false);
|
||||
const handleOpen = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
if (!savedConfig || !JSON.parse(savedConfig).accessToken) {
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
const overallProgressListener = (...args: unknown[]) => {
|
||||
const value = args[0] as number; // 0..100
|
||||
setProgress(value);
|
||||
setBuffer(Math.min(value + 10, 100));
|
||||
};
|
||||
|
||||
const statusListener = (...args: unknown[]) => {
|
||||
const status = args[0] as { step: string; message: string };
|
||||
setInstallStep(status.step);
|
||||
setInstallMessage(status.message);
|
||||
};
|
||||
|
||||
const minecraftErrorListener = (...args: unknown[]) => {
|
||||
const payload = (args[0] || {}) as {
|
||||
message?: string;
|
||||
stderr?: string;
|
||||
code?: number;
|
||||
};
|
||||
|
||||
// Главное — показать пользователю, что запуск не удался
|
||||
showNotification(
|
||||
payload.message ||
|
||||
'Minecraft завершился с ошибкой. Подробности смотрите в логах.',
|
||||
'error',
|
||||
);
|
||||
};
|
||||
|
||||
const minecraftStartedListener = () => {
|
||||
setIsGameRunning(true);
|
||||
};
|
||||
|
||||
const minecraftStoppedListener = () => {
|
||||
setIsGameRunning(false);
|
||||
};
|
||||
|
||||
window.electron.ipcRenderer.on('overall-progress', overallProgressListener);
|
||||
window.electron.ipcRenderer.on('minecraft-error', minecraftErrorListener);
|
||||
window.electron.ipcRenderer.on('installation-status', statusListener);
|
||||
window.electron.ipcRenderer.on(
|
||||
'minecraft-started',
|
||||
minecraftStartedListener,
|
||||
);
|
||||
window.electron.ipcRenderer.on(
|
||||
'minecraft-stopped',
|
||||
minecraftStoppedListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
// Удаляем только конкретных слушателей, а не всех
|
||||
// Это безопаснее, чем removeAllListeners
|
||||
const cleanup = window.electron.ipcRenderer.on;
|
||||
if (typeof cleanup === 'function') {
|
||||
cleanup('installation-status', statusListener);
|
||||
cleanup('minecraft-error', statusListener);
|
||||
cleanup('overall-progress', overallProgressListener);
|
||||
}
|
||||
// Удаляем использование removeAllListeners
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVersionConfig = async () => {
|
||||
if (!versionId) return;
|
||||
|
||||
try {
|
||||
// Сначала проверяем, есть ли конфигурация в localStorage
|
||||
const savedConfig = localStorage.getItem('selected_version_config');
|
||||
if (savedConfig) {
|
||||
const parsedConfig = JSON.parse(savedConfig);
|
||||
|
||||
// Если конфиг пустой — считаем, что он невалидный и идём по IPC-ветке
|
||||
if (Object.keys(parsedConfig).length > 0) {
|
||||
setVersionConfig(parsedConfig);
|
||||
|
||||
setConfig({
|
||||
memory: parsedConfig.memory || 4096,
|
||||
preserveFiles: parsedConfig.preserveFiles || [],
|
||||
});
|
||||
|
||||
localStorage.removeItem('selected_version_config');
|
||||
return;
|
||||
} else {
|
||||
localStorage.removeItem('selected_version_config');
|
||||
}
|
||||
}
|
||||
|
||||
// Если нет в localStorage, запрашиваем с сервера
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'get-version-config',
|
||||
{ versionId },
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setVersionConfig(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) {
|
||||
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 || [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchVersionConfig();
|
||||
}, [versionId]);
|
||||
|
||||
const showNotification = (
|
||||
message: string,
|
||||
severity: 'success' | 'error' | 'info',
|
||||
) => {
|
||||
setNotification({ open: true, message, severity });
|
||||
};
|
||||
|
||||
const handleCloseNotification = () => {
|
||||
setNotification({ ...notification, open: false });
|
||||
};
|
||||
|
||||
// Функция для запуска игры с настройками выбранной версии
|
||||
const handleLaunchMinecraft = async () => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
setBuffer(10);
|
||||
|
||||
// Используем настройки выбранной версии или дефолтные
|
||||
const currentConfig = versionConfig || {
|
||||
packName: versionId || 'Comfort',
|
||||
memory: 4096,
|
||||
baseVersion: '1.21.4',
|
||||
serverIp: 'popa-popa.ru',
|
||||
fabricVersion: '0.16.14',
|
||||
preserveFiles: [],
|
||||
};
|
||||
|
||||
// Проверяем, является ли это ванильной версией
|
||||
const isVanillaVersion =
|
||||
!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(
|
||||
localStorage.getItem('launcher_config') || '{}',
|
||||
);
|
||||
|
||||
const options = {
|
||||
accessToken: savedConfig.accessToken,
|
||||
uuid: savedConfig.uuid,
|
||||
username: savedConfig.username,
|
||||
memory: config.memory,
|
||||
baseVersion: currentConfig.baseVersion,
|
||||
packName: versionId || currentConfig.packName,
|
||||
serverIp: currentConfig.serverIp,
|
||||
fabricVersion: currentConfig.fabricVersion,
|
||||
// Для ванильной версии устанавливаем флаг
|
||||
isVanillaVersion: isVanillaVersion,
|
||||
versionToLaunchOverride: isVanillaVersion ? versionId : undefined,
|
||||
};
|
||||
|
||||
const launchResult = await window.electron.ipcRenderer.invoke(
|
||||
'launch-minecraft',
|
||||
options,
|
||||
);
|
||||
|
||||
if (launchResult?.success) {
|
||||
showNotification('Minecraft успешно запущен!', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification(`Ошибка: ${error.message}`, 'error');
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopMinecraft = async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('stop-minecraft');
|
||||
|
||||
if (result?.success) {
|
||||
showNotification('Minecraft остановлен', 'info');
|
||||
setIsGameRunning(false);
|
||||
} else if (result?.error) {
|
||||
showNotification(
|
||||
`Не удалось остановить Minecraft: ${result.error}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка при остановке Minecraft:', error);
|
||||
showNotification(
|
||||
`Ошибка при остановке Minecraft: ${error.message || String(error)}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для сохранения настроек
|
||||
const savePackConfig = async () => {
|
||||
try {
|
||||
const configToSave = {
|
||||
memory: config.memory,
|
||||
preserveFiles: config.preserveFiles || [],
|
||||
};
|
||||
|
||||
await window.electron.ipcRenderer.invoke('save-pack-config', {
|
||||
packName: versionId || versionConfig?.packName || 'Comfort',
|
||||
config: configToSave,
|
||||
});
|
||||
|
||||
// Обновляем launchOptions
|
||||
launchOptions.memory = config.memory;
|
||||
|
||||
showNotification('Настройки сохранены', 'success');
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении настроек:', error);
|
||||
showNotification('Ошибка сохранения настроек', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ gap: '1vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<PopaPopa />
|
||||
|
||||
<Typography variant="h4">Игровой сервер</Typography>
|
||||
|
||||
<Typography variant="h4">долбаёбов в Minecraft</Typography>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
|
||||
СЕРВЕР ГДЕ ВСЕМ НА ВАС ПОХУЙ
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
|
||||
СЕРВЕР ГДЕ РАЗРЕШЕНЫ ОДНОПОЛЫЕ БРАКИ
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
|
||||
СЕРВЕР ГДЕ ВСЕ ДОЛБАЕБЫ
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
|
||||
СЕРВЕР ГДЕ НА СПАВНЕ БУДЕТ ХУЙ (ВОЗМОЖНО)
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
|
||||
СЕРВЕР ЗА КОТОРЫЙ ВЫ ПРОДАДИТЕ МАТЬ
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
|
||||
ТЫ МОЖЕШЬ КУПИТЬ АДМИНКУ И ПОЛУЧИТЬ ПИЗДЫ
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<ServerStatus
|
||||
serverIp={versionConfig?.serverIp || 'popa-popa.ru'}
|
||||
refreshInterval={30000}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{isDownloading ? (
|
||||
<Box sx={{ mb: 3, width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Box sx={{ width: '100%', mr: 1 }}>
|
||||
<LinearProgress
|
||||
variant="buffer"
|
||||
value={progress}
|
||||
valueBuffer={buffer}
|
||||
sx={{
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
|
||||
// Фон прогресс-бара (buffer background)
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
|
||||
'& .MuiLinearProgress-bar1Buffer': {
|
||||
// Основная прогресс-линия
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
borderRadius: 6,
|
||||
},
|
||||
|
||||
'& .MuiLinearProgress-bar2Buffer': {
|
||||
// Buffer линия (вторая линия)
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
borderRadius: 6,
|
||||
},
|
||||
|
||||
'& .MuiLinearProgress-dashed': {
|
||||
// Линии пунктирного эффекта
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 35 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'white' }}
|
||||
>{`${Math.round(progress)}%`}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: '1vw',
|
||||
width: '100%', // родитель занимает всю ширину
|
||||
}}
|
||||
>
|
||||
{/* Первая кнопка — растягивается на всё доступное пространство */}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={
|
||||
isGameRunning ? handleStopMinecraft : handleLaunchMinecraft
|
||||
}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
width: 'auto',
|
||||
borderRadius: '3vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
transition: 'transform 0.3s ease',
|
||||
|
||||
...(isGameRunning
|
||||
? {
|
||||
// 🔹 Стиль, когда игра запущена (серая кнопка)
|
||||
background: 'linear-gradient(71deg, #555 0%, #777 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(71deg, #666 0%, #888 100%)',
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 4px 15px rgba(100, 100, 100, 0.4)',
|
||||
},
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
||||
}
|
||||
: {
|
||||
// 🔹 Стиль, когда Minecraft НЕ запущен (твоя стандартная красочная кнопка)
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
'&:hover': {
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 4px 15px rgba(242, 113, 33, 0.4)',
|
||||
},
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{isGameRunning ? 'Остановить Minecraft' : 'Запустить Minecraft'}
|
||||
</Button>
|
||||
|
||||
{/* Вторая кнопка — квадратная, фиксированного размера (ширина = высоте) */}
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{
|
||||
flexShrink: 0, // не сжимается
|
||||
aspectRatio: '1', // ширина = высоте
|
||||
backgroundColor: 'grey',
|
||||
borderRadius: '3vw',
|
||||
minHeight: 'unset',
|
||||
minWidth: 'unset',
|
||||
height: '100%', // занимает полную высоту родителя
|
||||
transition: 'transform 0.3s ease',
|
||||
'&:hover': {
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 4px 15px rgba(242, 113, 33, 0.4)',
|
||||
},
|
||||
}}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={notification.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleCloseNotification}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseNotification}
|
||||
severity={notification.severity}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{notification.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
<SettingsModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
config={config}
|
||||
onConfigChange={setConfig}
|
||||
packName={versionId || versionConfig?.packName || 'Comfort'}
|
||||
onSave={savePackConfig}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LaunchPage;
|
||||
130
src/renderer/pages/Login.tsx
Normal file
130
src/renderer/pages/Login.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import useAuth from '../hooks/useAuth';
|
||||
import AuthForm from '../components/Login/AuthForm';
|
||||
import MemorySlider from '../components/Login/MemorySlider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PopaPopa from '../components/popa-popa';
|
||||
import useConfig from '../hooks/useConfig';
|
||||
import { useState } from 'react';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess?: (username: string) => void;
|
||||
}
|
||||
|
||||
const Login = ({ onLoginSuccess }: LoginProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { config, setConfig, saveConfig, handleInputChange } = useConfig();
|
||||
const { status, validateSession, refreshSession, authenticateWithElyBy } =
|
||||
useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const authorization = async () => {
|
||||
console.log('Начинаем процесс авторизации...');
|
||||
|
||||
if (!config.username.trim()) {
|
||||
console.log('Ошибка: не указан никнейм');
|
||||
alert('Введите никнейм!');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Проверяем, есть ли сохранённый токен
|
||||
if (config.accessToken && config.clientToken) {
|
||||
console.log('Проверка валидности существующего токена...');
|
||||
const isValid = await validateSession(config.accessToken);
|
||||
|
||||
if (!isValid) {
|
||||
console.log('Токен недействителен, пытаемся обновить...');
|
||||
const refreshedSession = await refreshSession(
|
||||
config.accessToken,
|
||||
config.clientToken,
|
||||
);
|
||||
|
||||
if (!refreshedSession) {
|
||||
console.log(
|
||||
'Не удалось обновить токен, требуется новая авторизация',
|
||||
);
|
||||
// Очищаем недействительные токены
|
||||
saveConfig({
|
||||
accessToken: '',
|
||||
clientToken: '',
|
||||
});
|
||||
|
||||
// Пытаемся выполнить новую авторизацию
|
||||
if (config.password) {
|
||||
const newSession = await authenticateWithElyBy(
|
||||
config.username,
|
||||
config.password,
|
||||
saveConfig,
|
||||
);
|
||||
if (!newSession) {
|
||||
console.log('Авторизация не удалась');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log('Требуется ввод пароля для новой авторизации');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('Токен действителен');
|
||||
}
|
||||
} else {
|
||||
console.log('Токен отсутствует, выполняем авторизацию...');
|
||||
// Проверяем наличие пароля
|
||||
if (!config.password) {
|
||||
console.log('Ошибка: не указан пароль');
|
||||
alert('Введите пароль!');
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await authenticateWithElyBy(
|
||||
config.username,
|
||||
config.password,
|
||||
saveConfig,
|
||||
);
|
||||
if (!session) {
|
||||
console.log('Авторизация не удалась');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Авторизация успешно завершена');
|
||||
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(config.username);
|
||||
}
|
||||
|
||||
navigate('/');
|
||||
} catch (error: any) {
|
||||
console.log(`ОШИБКА при авторизации: ${error.message}`);
|
||||
saveConfig({
|
||||
accessToken: '',
|
||||
clientToken: '',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{loading ? (
|
||||
<FullScreenLoader message="Входим..." />
|
||||
) : (
|
||||
<>
|
||||
<PopaPopa />
|
||||
<AuthForm
|
||||
config={config}
|
||||
handleInputChange={handleInputChange}
|
||||
onLogin={authorization}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
503
src/renderer/pages/Marketplace.tsx
Normal file
503
src/renderer/pages/Marketplace.tsx
Normal file
@ -0,0 +1,503 @@
|
||||
// src/renderer/pages/Marketplace.tsx
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
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';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
|
||||
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 translateServer = (server: Server) => {
|
||||
switch (server.name) {
|
||||
case 'Server minecraft.hub.popa-popa.ru':
|
||||
return 'Хаб';
|
||||
case 'Server survival.hub.popa-popa.ru':
|
||||
return 'Выживание';
|
||||
case 'Server minecraft.minigames.popa-popa.ru':
|
||||
return 'Миниигры';
|
||||
default:
|
||||
return server.name;
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для проверки онлайн-статуса игрока и определения сервера
|
||||
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: '20vh',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<FullScreenLoader
|
||||
fullScreen={true}
|
||||
message="Проверяем, находитесь ли вы на сервере..."
|
||||
/>
|
||||
</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, width: '95%', height: '80%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: '1vw' }}>
|
||||
<Typography variant="h4" color="white" gutterBottom>
|
||||
Рынок сервера{' '}
|
||||
</Typography>
|
||||
<Typography
|
||||
style={{
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(255, 77, 77, 1)',
|
||||
padding: '0vw 2vw',
|
||||
borderRadius: '5vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
textAlign: 'center',
|
||||
fontSize: '2vw',
|
||||
}}
|
||||
>
|
||||
{translateServer(playerServer || { name: '' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Вкладки */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'transparent' }}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
aria-label="marketplace tabs"
|
||||
disableRipple={true}
|
||||
sx={{
|
||||
'& .MuiTabs-indicator': {
|
||||
backgroundColor: 'rgba(255, 77, 77, 1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
label="Товары"
|
||||
disableRipple={true}
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'white',
|
||||
'&.Mui-selected': {
|
||||
color: 'rgba(255, 77, 77, 1)',
|
||||
},
|
||||
'&:hover': {
|
||||
color: 'rgba(255, 77, 77, 1)',
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Мой инвентарь"
|
||||
disableRipple={true}
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'white',
|
||||
'&.Mui-selected': {
|
||||
color: 'rgba(255, 77, 77, 1)',
|
||||
},
|
||||
'&:hover': {
|
||||
color: 'rgba(255, 77, 77, 1)',
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
<Tab
|
||||
label="Мои товары"
|
||||
disableRipple={true}
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'white',
|
||||
'&.Mui-selected': {
|
||||
color: 'rgba(255, 77, 77, 1)',
|
||||
},
|
||||
'&:hover': {
|
||||
color: 'rgba(255, 77, 77, 1)',
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Содержимое вкладки "Товары" */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
{marketLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: '50vw' }}>
|
||||
<FullScreenLoader fullScreen={false} />
|
||||
</Box>
|
||||
) : !marketItems || marketItems.items.length === 0 ? (
|
||||
<Box sx={{ mt: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="white" sx={{ mt: '10vw' }}>
|
||||
На данный момент на рынке нет предметов.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
playerServer && loadMarketItems(playerServer.ip, 1)
|
||||
}
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: '20px',
|
||||
p: '10px 25px',
|
||||
color: 'white',
|
||||
backgroundColor: 'rgb(255, 77, 77)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 77, 77, 0.5)',
|
||||
},
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1vw',
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
</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)',
|
||||
borderRadius: '1vw',
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
sx={{
|
||||
minWidth: '10vw',
|
||||
minHeight: '10vw',
|
||||
maxHeight: '10vw',
|
||||
objectFit: 'contain',
|
||||
p: '1vw',
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
image={`https://cdn.minecraft.popa-popa.ru/textures/${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: '1vw',
|
||||
borderRadius: '20px',
|
||||
p: '0.3vw 0vw',
|
||||
color: 'white',
|
||||
backgroundColor: 'rgb(255, 77, 77)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 77, 77, 0.5)',
|
||||
},
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1vw',
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
721
src/renderer/pages/News.tsx
Normal file
721
src/renderer/pages/News.tsx
Normal file
@ -0,0 +1,721 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Stack,
|
||||
Chip,
|
||||
IconButton,
|
||||
TextField,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { fetchNews, NewsItem, createNews, fetchMe, deleteNews } from '../api';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
import { MarkdownEditor } from '../components/MarkdownEditor';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
|
||||
export const News = () => {
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// Markdown-рендерер (динамический импорт, чтобы не ругался CommonJS)
|
||||
const [ReactMarkdown, setReactMarkdown] = useState<any>(null);
|
||||
const [remarkGfm, setRemarkGfm] = useState<any>(null);
|
||||
|
||||
// --- Админский редактор ---
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [preview, setPreview] = useState('');
|
||||
const [markdown, setMarkdown] = useState('');
|
||||
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const me = await fetchMe();
|
||||
setIsAdmin(me.is_admin);
|
||||
} catch {
|
||||
setIsAdmin(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Загружаем react-markdown + remark-gfm
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const md = await import('react-markdown');
|
||||
const gfm = await import('remark-gfm');
|
||||
setReactMarkdown(() => md.default);
|
||||
setRemarkGfm(() => gfm.default);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Загрузка списка новостей
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchNews();
|
||||
setNews(data);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Не удалось загрузить новости');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleToggleExpand = (id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
const handleCreateNews = async () => {
|
||||
if (!title.trim() || !markdown.trim()) {
|
||||
setError('У новости должны быть заголовок и текст');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
await createNews({
|
||||
title: title.trim(),
|
||||
preview: preview.trim() || undefined,
|
||||
markdown,
|
||||
is_published: true,
|
||||
});
|
||||
|
||||
const updated = await fetchNews();
|
||||
setNews(updated);
|
||||
|
||||
// Сброс формы
|
||||
setTitle('');
|
||||
setPreview('');
|
||||
setMarkdown('');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Не удалось создать новость');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNews = async (id: string) => {
|
||||
const confirmed = window.confirm('Точно удалить эту новость?');
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteNews(id);
|
||||
setNews((prev) => prev.filter((n) => n.id !== id));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Не удалось удалить новость');
|
||||
}
|
||||
};
|
||||
|
||||
// ждём пока react-markdown / remark-gfm загрузятся
|
||||
if (!ReactMarkdown || !remarkGfm) {
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
|
||||
if (error && news.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mt: '10vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
px: '3vw',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: '#ff8080',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mt: '7vh',
|
||||
px: '7vw',
|
||||
pb: '4vh',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2vh',
|
||||
}}
|
||||
>
|
||||
{/* Заголовок страницы */}
|
||||
<Box
|
||||
sx={{
|
||||
mb: '2vh',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '3vw',
|
||||
backgroundImage:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
Новости
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
}}
|
||||
>
|
||||
Последние обновления лаунчера, сервера и ивентов
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Админский редактор */}
|
||||
{isAdmin && (
|
||||
<Paper
|
||||
sx={{
|
||||
mb: 3,
|
||||
p: 2.5,
|
||||
borderRadius: '1.5vw',
|
||||
background:
|
||||
'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1.1vw',
|
||||
mb: 1.5,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
}}
|
||||
>
|
||||
Создать новость
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label="Заголовок"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiInputBase-root': {
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
color: 'white',
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Краткий превью-текст (опционально)"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={preview}
|
||||
onChange={(e) => setPreview(e.target.value)}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiInputBase-root': {
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
color: 'white',
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .EasyMDEContainer': {
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: '0.8vw',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
},
|
||||
'& .editor-toolbar': {
|
||||
background: 'transparent',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 1)',
|
||||
color: 'white',
|
||||
},
|
||||
'& .editor-toolbar .fa': {
|
||||
color: 'white',
|
||||
},
|
||||
'& .CodeMirror': {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'white',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MarkdownEditor value={markdown} onChange={setMarkdown} />
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: '#ff8080',
|
||||
fontSize: '0.8vw',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={creating}
|
||||
onClick={handleCreateNews}
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 0.8,
|
||||
borderRadius: '999px',
|
||||
textTransform: 'uppercase',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '0.8vw',
|
||||
letterSpacing: '0.08em',
|
||||
backgroundImage:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
|
||||
boxShadow: '0 12px 30px rgba(0,0,0,0.9)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 18px 40px rgba(0,0,0,1)',
|
||||
filter: 'brightness(1.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{creating ? 'Публикация...' : 'Опубликовать'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Если новостей нет */}
|
||||
{news.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: '5vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
px: '3vw',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '2vw',
|
||||
backgroundImage:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Новостей пока нет
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Список новостей */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.8vh',
|
||||
}}
|
||||
>
|
||||
{news.map((item) => {
|
||||
const isExpanded = expandedId === item.id;
|
||||
|
||||
const shortContent = item.preview || item.markdown;
|
||||
const fullContent = item.markdown;
|
||||
const contentToRender = isExpanded ? fullContent : shortContent;
|
||||
|
||||
const isImageUrl =
|
||||
!isExpanded &&
|
||||
typeof shortContent === 'string' &&
|
||||
/^https?:\/\/.*\.(png|jpe?g|gif|webp)$/i.test(shortContent.trim());
|
||||
|
||||
return (
|
||||
<Paper
|
||||
key={item.id}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
mb: 1,
|
||||
p: 2.5,
|
||||
borderRadius: '1.5vw',
|
||||
background:
|
||||
'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
width: '80vw',
|
||||
transition:
|
||||
'transform 0.25s ease, box-shadow 0.25s.ease, border-color 0.25s ease',
|
||||
'&:hover': {
|
||||
boxShadow: '0 24px 60px rgba(0, 0, 0, 0.9)',
|
||||
borderColor: 'rgba(242,113,33,0.5)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Шапка новости */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: 'white',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '2.5vw',
|
||||
mb: 0.5,
|
||||
textShadow: '0 0 18px rgba(0,0,0,0.8)',
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Typography>
|
||||
|
||||
{item.created_at && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: '0.85vw',
|
||||
}}
|
||||
>
|
||||
{new Date(item.created_at).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ mt: 1, flexWrap: 'wrap' }}
|
||||
>
|
||||
{item.tags.map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
label={tag}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '0.7vw',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid rgba(242,113,33,0.6)',
|
||||
background:
|
||||
'linear-gradient(120deg, rgba(242,113,33,0.18), rgba(233,64,87,0.12), rgba(138,35,135,0.16))',
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
onClick={() => handleToggleExpand(item.id)}
|
||||
sx={{
|
||||
ml: 1,
|
||||
alignSelf: 'center',
|
||||
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.25s ease, background 0.25s ease',
|
||||
background:
|
||||
'linear-gradient(140deg, rgba(242,113,33,0.15), rgba(233,64,87,0.15))',
|
||||
borderRadius: '1.4vw',
|
||||
'&:hover': {
|
||||
background:
|
||||
'linear-gradient(140deg, rgba(242,113,33,0.4), rgba(233,64,87,0.4))',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExpandMoreIcon
|
||||
sx={{ color: 'rgba(255,255,255,0.9)', fontSize: '1.4vw' }}
|
||||
/>
|
||||
</IconButton>
|
||||
{isAdmin && (
|
||||
<IconButton
|
||||
onClick={() => handleDeleteNews(item.id)}
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
backgroundColor: 'rgba(255, 77, 77, 0.1)',
|
||||
borderRadius: '1.4vw',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 77, 77, 0.3)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon
|
||||
sx={{
|
||||
color: 'rgba(255, 120, 120, 0.9)',
|
||||
fontSize: '1.2vw',
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Контент */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
mt: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{isImageUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={(shortContent as string).trim()}
|
||||
alt={item.title}
|
||||
sx={{
|
||||
maxHeight: '30vh',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '1.2vw',
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: isExpanded ? 'none' : '12em',
|
||||
overflow: 'hidden',
|
||||
pr: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ node, ...props }) => (
|
||||
<Typography
|
||||
{...props}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
fontSize: '1.5vw',
|
||||
lineHeight: 1.6,
|
||||
mb: 1,
|
||||
whiteSpace: 'pre-line', // ← вот это
|
||||
'&:last-of-type': { mb: 0 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
strong: ({ node, ...props }) => (
|
||||
<Box
|
||||
component="strong"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: 'rgba(255,255,255,1)',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
em: ({ node, ...props }) => (
|
||||
<Box
|
||||
component="em"
|
||||
sx={{ fontStyle: 'italic' }}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
del: ({ node, ...props }) => (
|
||||
<Box
|
||||
component="del"
|
||||
sx={{ textDecoration: 'line-through' }}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
a: ({ node, ...props }) => (
|
||||
<Box
|
||||
component="a"
|
||||
{...props}
|
||||
sx={{
|
||||
color: '#F27121',
|
||||
textDecoration: 'none',
|
||||
borderBottom: '1px solid rgba(242,113,33,0.6)',
|
||||
'&:hover': {
|
||||
color: '#E940CD',
|
||||
borderBottomColor: 'rgba(233,64,205,0.8)',
|
||||
},
|
||||
}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
),
|
||||
li: ({ node, ordered, ...props }) => (
|
||||
<li
|
||||
style={{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
fontSize: '1.5vw',
|
||||
marginBottom: '0.3em',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul
|
||||
style={{
|
||||
paddingLeft: '1.3em',
|
||||
marginTop: '0.3em',
|
||||
marginBottom: '0.8em',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol
|
||||
style={{
|
||||
paddingLeft: '1.3em',
|
||||
marginTop: '0.3em',
|
||||
marginBottom: '0.8em',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
img: ({ node, ...props }) => (
|
||||
<Box
|
||||
component="img"
|
||||
{...props}
|
||||
sx={{
|
||||
maxHeight: '30vh',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '1.2vw',
|
||||
my: 2,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h1: ({ node, ...props }) => (
|
||||
<Typography
|
||||
{...props}
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'white',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h2: ({ node, ...props }) => (
|
||||
<Typography
|
||||
{...props}
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'white',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h3: ({ node, ...props }) => (
|
||||
<Typography
|
||||
{...props}
|
||||
variant="h7"
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'white',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{contentToRender}
|
||||
</ReactMarkdown>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isExpanded && !isImageUrl && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3.5em',
|
||||
// background:
|
||||
// 'linear-gradient(to top, rgba(0, 0, 0, 0.43), rgba(0,0,0,0))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
onClick={() => handleToggleExpand(item.id)}
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '0.8vw',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
cursor: 'pointer',
|
||||
backgroundImage:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
textShadow: '0 0 15px rgba(0,0,0,0.9)',
|
||||
'&:hover': {
|
||||
opacity: 0.85,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isExpanded ? 'Свернуть' : 'Читать полностью'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
459
src/renderer/pages/Profile.tsx
Normal file
459
src/renderer/pages/Profile.tsx
Normal file
@ -0,0 +1,459 @@
|
||||
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,
|
||||
} from '@mui/material';
|
||||
|
||||
import CapeCard from '../components/CapeCard';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
import { OnlinePlayersPanel } from '../components/OnlinePlayersPanel';
|
||||
|
||||
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);
|
||||
|
||||
const [viewerWidth, setViewerWidth] = useState(500);
|
||||
const [viewerHeight, setViewerHeight] = useState(600);
|
||||
|
||||
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 || '');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Функция для обновления размеров
|
||||
const updateDimensions = () => {
|
||||
setViewerWidth(window.innerWidth * 0.4); // 25vw
|
||||
setViewerHeight(window.innerWidth * 0.5); // 30vw
|
||||
};
|
||||
|
||||
// Вызываем один раз при монтировании
|
||||
updateDimensions();
|
||||
|
||||
// Добавляем слушатель изменения размера окна
|
||||
window.addEventListener('resize', updateDimensions);
|
||||
|
||||
// Очистка при размонтировании
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateDimensions);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 ? (
|
||||
<FullScreenLoader message="Загрузка вашего профиля" />
|
||||
) : (
|
||||
<>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 0,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{/* Используем переработанный компонент SkinViewer */}
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
alignSelf: 'center',
|
||||
justifySelf: 'center',
|
||||
textAlign: 'center',
|
||||
mb: '2vw',
|
||||
fontSize: '3vw',
|
||||
color: 'white',
|
||||
borderRadius: '3vw',
|
||||
p: '0.5vw 5vw',
|
||||
bgcolor: 'rgb(255, 77, 77)',
|
||||
boxShadow: '0 0 1vw 0 rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
>
|
||||
{username}
|
||||
</Typography>
|
||||
<SkinViewer
|
||||
width={viewerWidth}
|
||||
height={viewerHeight}
|
||||
skinUrl={skin}
|
||||
capeUrl={cape}
|
||||
walkingSpeed={walkingSpeed}
|
||||
autoRotate={true}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1vw',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '40vw',
|
||||
maxWidth: '40vw',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
padding: '3vw',
|
||||
borderRadius: '1vw',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed',
|
||||
borderColor: isDragOver ? 'primary.main' : 'grey.400',
|
||||
borderRadius: '1vw',
|
||||
p: '1.5vw',
|
||||
mb: '1vw',
|
||||
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: '1vw',
|
||||
color: 'white',
|
||||
'&:hover .MuiInputLabel-root': {
|
||||
color: 'rgb(255, 77, 77)',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<InputLabel
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'white',
|
||||
'&.Mui-focused': {
|
||||
color: 'rgb(255, 77, 77)',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
},
|
||||
transform: 'translate(14px, -9px) scale(0.75)',
|
||||
'&.MuiInputLabel-shrink': {
|
||||
transform: 'translate(14px, -9px) scale(0.75)',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Модель скина
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={skinModel}
|
||||
label="Модель скина"
|
||||
onChange={(e) => setSkinModel(e.target.value)}
|
||||
displayEmpty
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiSelect-select': {
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'white',
|
||||
paddingTop: '1vw',
|
||||
paddingBottom: '1vw',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
},
|
||||
'&:hover': {
|
||||
'& .MuiSelect-select': {
|
||||
color: 'rgb(255, 77, 77)',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
},
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
'& .MuiSelect-select': {
|
||||
color: 'rgb(255, 77, 77)',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
},
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderRadius: '5vw',
|
||||
borderColor: 'rgb(255, 255, 255)',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'rgb(255, 77, 77)',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'rgb(255, 77, 77)',
|
||||
borderWidth: '2px',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
},
|
||||
'& .MuiSelect-icon': {
|
||||
color: 'white',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
},
|
||||
'&:hover .MuiSelect-icon': {
|
||||
color: 'rgb(255, 77, 77)',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
},
|
||||
'&.Mui-focused .MuiSelect-icon': {
|
||||
color: 'rgb(255, 77, 77)',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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' ? (
|
||||
<FullScreenLoader fullScreen={false} />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{uploadStatus === 'loading' ? (
|
||||
<FullScreenLoader message="Загрузка..." />
|
||||
) : (
|
||||
<Typography sx={{ color: 'white' }}>
|
||||
Установить скин
|
||||
</Typography>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: '2vw',
|
||||
}}
|
||||
>
|
||||
<Typography>Ваши плащи</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '2vw',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{capes.map((cape) => (
|
||||
<CapeCard
|
||||
key={cape.cape_id}
|
||||
cape={cape}
|
||||
mode="profile"
|
||||
onAction={
|
||||
cape.is_active ? handleDeactivateCape : handleActivateCape
|
||||
}
|
||||
actionDisabled={loading}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<OnlinePlayersPanel currentUsername={username} />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
453
src/renderer/pages/Registration.tsx
Normal file
453
src/renderer/pages/Registration.tsx
Normal file
@ -0,0 +1,453 @@
|
||||
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,
|
||||
} 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';
|
||||
import GradientTextField from '../components/GradientTextField';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
|
||||
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%)',
|
||||
'linear-gradient( 95deg,rgb(150,150,150) 0%, rgb(242,113,33) 80%,rgb(233,64,87) 110%,rgb(138,35,135) 150%)',
|
||||
},
|
||||
},
|
||||
[`&.${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,
|
||||
backgroundImage:
|
||||
'linear-gradient( 275deg,rgb(150,150,150) 0%, rgb(242,113,33) 80%,rgb(233,64,87) 110%,rgb(138,35,135) 150%)',
|
||||
borderRadius: 1,
|
||||
transition: 'background-image 1s ease, background-color 1s ease',
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: theme.palette.grey[800],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const ColorlibStepIconRoot = styled('div')<{
|
||||
ownerState: { completed?: boolean; active?: boolean };
|
||||
}>(({ theme }) => ({
|
||||
backgroundColor: '#adadad',
|
||||
zIndex: 1,
|
||||
color: '#fff',
|
||||
width: 50,
|
||||
height: 50,
|
||||
display: 'flex',
|
||||
borderRadius: '50%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
transition: 'background-image 1s ease, box-shadow 1s ease, transform 1s ease',
|
||||
...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)',
|
||||
transform: 'scale(1.08)',
|
||||
},
|
||||
},
|
||||
{
|
||||
props: ({ ownerState }) => ownerState.completed,
|
||||
style: {
|
||||
backgroundImage: '#adadad',
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
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,
|
||||
data: 'https://t.me/popa_popa_popa_bot?start=test',
|
||||
shape: 'square',
|
||||
margin: 10,
|
||||
dotsOptions: {
|
||||
gradient: {
|
||||
type: 'linear',
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgb(242,113,33)',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgb(233,64,87)',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'extra-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 [enterpassword, setEnterPassword] = 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 () => {
|
||||
// простая валидация на фронте
|
||||
if (!username || !password || !enterpassword) {
|
||||
setOpen(true);
|
||||
setMessage('Заполните все поля');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== enterpassword) {
|
||||
setOpen(true);
|
||||
setMessage('Пароли не совпадают');
|
||||
return;
|
||||
}
|
||||
|
||||
// тут уже точно всё ок — отправляем запрос
|
||||
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}`);
|
||||
|
||||
while (ref.current.firstChild) {
|
||||
ref.current.removeChild(ref.current.firstChild);
|
||||
}
|
||||
|
||||
const newQrCode = new QRCodeStyling({
|
||||
width: 300,
|
||||
height: 300,
|
||||
image: popalogo,
|
||||
data: 'https://t.me/popa_popa_popa_bot?start=test',
|
||||
shape: 'square',
|
||||
margin: 10,
|
||||
dotsOptions: {
|
||||
gradient: {
|
||||
type: 'linear',
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgb(242,113,33)',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgb(233,64,87)',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'extra-rounded',
|
||||
},
|
||||
imageOptions: {
|
||||
crossOrigin: 'anonymous',
|
||||
margin: 20,
|
||||
imageSize: 0.5,
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: 'transparent',
|
||||
},
|
||||
});
|
||||
|
||||
newQrCode.update({
|
||||
data: `https://t.me/popa_popa_popa_bot?start=${username}`,
|
||||
});
|
||||
|
||||
setUrl(`https://t.me/popa_popa_popa_bot?start=${username}`);
|
||||
|
||||
newQrCode.append(ref.current);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Stepper
|
||||
activeStep={activeStep}
|
||||
alternativeLabel
|
||||
connector={<ColorlibConnector />}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '10%',
|
||||
}} // чтобы отделить степпер от формы
|
||||
>
|
||||
{steps.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel
|
||||
sx={{
|
||||
'& .MuiStepLabel-label': {
|
||||
color: '#adadad !important',
|
||||
transition: 'color 1s ease',
|
||||
},
|
||||
'& .Mui-completed': {
|
||||
color: '#adadad !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',
|
||||
transition: 'all 1s ease',
|
||||
},
|
||||
}}
|
||||
StepIconComponent={ColorlibStepIcon}
|
||||
>
|
||||
{label}
|
||||
</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1vh',
|
||||
width: '50vw',
|
||||
mt: activeStep === 1 ? '20%' : '0%',
|
||||
}}
|
||||
>
|
||||
{activeStep === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<GradientTextField
|
||||
label="Никнейм"
|
||||
required
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<GradientTextField
|
||||
label="Пароль"
|
||||
required
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<GradientTextField
|
||||
label="Подтвердите пароль"
|
||||
required
|
||||
name="enterpassword"
|
||||
type="password"
|
||||
value={enterpassword}
|
||||
onChange={(e) => setEnterPassword(e.target.value)}
|
||||
error={Boolean(enterpassword) && password !== enterpassword}
|
||||
helperText={
|
||||
Boolean(enterpassword) && password !== enterpassword
|
||||
? 'Пароли не совпадают'
|
||||
: '⠀'
|
||||
}
|
||||
sx={{ mb: '0vw' }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{
|
||||
transition: 'transform 0.3s ease',
|
||||
width: '60%',
|
||||
mt: 2,
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderRadius: '2.5vw',
|
||||
fontSize: '2vw',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
onClick={handleCreateAccount}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeStep === 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{
|
||||
transition: 'transform 0.3s ease',
|
||||
width: '60%',
|
||||
mt: 2,
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderRadius: '2.5vw',
|
||||
fontSize: '2vw',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
onClick={handleOpenBot}
|
||||
>
|
||||
Открыть бота
|
||||
</Button>
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
minHeight: 300,
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<FullScreenLoader fullScreen={false} />
|
||||
</>
|
||||
) : (
|
||||
<FullScreenLoader fullScreen={false} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleClose}
|
||||
message={message}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
675
src/renderer/pages/Shop.tsx
Normal file
675
src/renderer/pages/Shop.tsx
Normal file
@ -0,0 +1,675 @@
|
||||
import { Box, Typography, Button, Grid, Snackbar, Alert } from '@mui/material';
|
||||
import {
|
||||
Cape,
|
||||
fetchCapes,
|
||||
fetchCapesStore,
|
||||
purchaseCape,
|
||||
StoreCape,
|
||||
Case,
|
||||
CaseItem,
|
||||
fetchCases,
|
||||
fetchCase,
|
||||
openCase,
|
||||
Server,
|
||||
fetchPlayer,
|
||||
BonusType,
|
||||
UserBonus,
|
||||
fetchBonusTypes,
|
||||
fetchUserBonuses,
|
||||
purchaseBonus,
|
||||
upgradeBonus,
|
||||
toggleBonusActivation,
|
||||
} from '../api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
import { getPlayerServer } from '../utils/playerOnlineCheck';
|
||||
import CaseRoulette from '../components/CaseRoulette';
|
||||
import BonusShopItem from '../components/BonusShopItem';
|
||||
import ShopItem from '../components/ShopItem';
|
||||
|
||||
function getRarityByWeight(
|
||||
weight?: number,
|
||||
): 'common' | 'rare' | 'epic' | 'legendary' {
|
||||
if (weight === undefined || weight === null) return 'common';
|
||||
|
||||
if (weight <= 5) return 'legendary';
|
||||
if (weight <= 20) return 'epic';
|
||||
if (weight <= 50) return 'rare';
|
||||
return 'common';
|
||||
}
|
||||
|
||||
function getRarityColor(weight?: number): string {
|
||||
const rarity = getRarityByWeight(weight);
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return 'rgba(255, 215, 0, 1)'; // золотой
|
||||
case 'epic':
|
||||
return 'rgba(186, 85, 211, 1)'; // фиолетовый
|
||||
case 'rare':
|
||||
return 'rgba(65, 105, 225, 1)'; // синий
|
||||
case 'common':
|
||||
default:
|
||||
return 'rgba(255, 255, 255, 0.25)'; // сероватый
|
||||
}
|
||||
}
|
||||
|
||||
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 [playerSkinUrl, setPlayerSkinUrl] = useState<string>('');
|
||||
|
||||
// Прокачка
|
||||
|
||||
const [bonusTypes, setBonusTypes] = useState<BonusType[]>([]);
|
||||
const [userBonuses, setUserBonuses] = useState<UserBonus[]>([]);
|
||||
const [bonusesLoading, setBonusesLoading] = useState<boolean>(false);
|
||||
const [processingBonusIds, setProcessingBonusIds] = useState<string[]>([]);
|
||||
|
||||
// Кейсы
|
||||
const [cases, setCases] = useState<Case[]>([]);
|
||||
const [casesLoading, setCasesLoading] = useState<boolean>(false);
|
||||
|
||||
// Онлайн/сервер (по аналогии с Marketplace)
|
||||
const [isOnline, setIsOnline] = useState<boolean>(false);
|
||||
const [playerServer, setPlayerServer] = useState<Server | null>(null);
|
||||
const [onlineCheckLoading, setOnlineCheckLoading] = useState<boolean>(true);
|
||||
|
||||
// Рулетка
|
||||
const [isOpening, setIsOpening] = useState<boolean>(false);
|
||||
const [selectedCase, setSelectedCase] = useState<Case | null>(null);
|
||||
|
||||
const [rouletteOpen, setRouletteOpen] = useState(false);
|
||||
const [rouletteCaseItems, setRouletteCaseItems] = useState<CaseItem[]>([]);
|
||||
const [rouletteReward, setRouletteReward] = useState<CaseItem | null>(null);
|
||||
|
||||
// Уведомления
|
||||
const [notification, setNotification] = useState<{
|
||||
open: boolean;
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
}>({
|
||||
open: false,
|
||||
message: '',
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
const loadBonuses = async (username: string) => {
|
||||
try {
|
||||
setBonusesLoading(true);
|
||||
const [types, user] = await Promise.all([
|
||||
fetchBonusTypes(),
|
||||
fetchUserBonuses(username),
|
||||
]);
|
||||
setBonusTypes(types);
|
||||
setUserBonuses(user);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении прокачек:', error);
|
||||
setNotification({
|
||||
open: true,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Ошибка при загрузке прокачки',
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
setBonusesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlayerSkin = async (uuid: string) => {
|
||||
try {
|
||||
const player = await fetchPlayer(uuid);
|
||||
setPlayerSkinUrl(player.skin_url);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении скина игрока:', error);
|
||||
setPlayerSkinUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для загрузки плащей из магазина
|
||||
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);
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Плащ успешно куплен!',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка при покупке плаща:', error);
|
||||
setNotification({
|
||||
open: true,
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Ошибка при покупке плаща',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузка кейсов
|
||||
const loadCases = async () => {
|
||||
try {
|
||||
setCasesLoading(true);
|
||||
const casesData = await fetchCases();
|
||||
setCases(casesData);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении кейсов:', error);
|
||||
setCases([]);
|
||||
} finally {
|
||||
setCasesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Проверка онлайна игрока (по аналогии с Marketplace.tsx)
|
||||
const checkPlayerStatus = async () => {
|
||||
if (!username) return;
|
||||
|
||||
try {
|
||||
setOnlineCheckLoading(true);
|
||||
const { online, server } = await getPlayerServer(username);
|
||||
setIsOnline(online);
|
||||
setPlayerServer(server || null);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке онлайн-статуса:', error);
|
||||
setIsOnline(false);
|
||||
setPlayerServer(null);
|
||||
} finally {
|
||||
setOnlineCheckLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Загружаем базовые данные при монтировании
|
||||
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),
|
||||
loadCases(),
|
||||
loadPlayerSkin(config.uuid),
|
||||
loadBonuses(config.username),
|
||||
])
|
||||
.catch((err) => console.error(err))
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Проверяем онлайн после того, как знаем username
|
||||
useEffect(() => {
|
||||
if (username) {
|
||||
checkPlayerStatus();
|
||||
}
|
||||
}, [username]);
|
||||
|
||||
const withProcessing = async (id: string, fn: () => Promise<void>) => {
|
||||
setProcessingBonusIds((prev) => [...prev, id]);
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
setProcessingBonusIds((prev) => prev.filter((x) => x !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurchaseBonus = async (bonusTypeId: string) => {
|
||||
if (!username) {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Не найдено имя игрока. Авторизуйтесь в лаунчере.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await withProcessing(bonusTypeId, async () => {
|
||||
try {
|
||||
const res = await purchaseBonus(username, bonusTypeId);
|
||||
setNotification({
|
||||
open: true,
|
||||
message: res.message || 'Прокачка успешно куплена!',
|
||||
type: 'success',
|
||||
});
|
||||
await loadBonuses(username);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при покупке прокачки:', error);
|
||||
setNotification({
|
||||
open: true,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Ошибка при покупке прокачки',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpgradeBonus = async (bonusId: string) => {
|
||||
if (!username) return;
|
||||
|
||||
await withProcessing(bonusId, async () => {
|
||||
try {
|
||||
await upgradeBonus(username, bonusId);
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Бонус улучшен!',
|
||||
type: 'success',
|
||||
});
|
||||
await loadBonuses(username);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при улучшении бонуса:', error);
|
||||
setNotification({
|
||||
open: true,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Ошибка при улучшении бонуса',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleBonusActivation = async (bonusId: string) => {
|
||||
if (!username) return;
|
||||
|
||||
await withProcessing(bonusId, async () => {
|
||||
try {
|
||||
await toggleBonusActivation(username, bonusId);
|
||||
await loadBonuses(username);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при переключении бонуса:', error);
|
||||
setNotification({
|
||||
open: true,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Ошибка при переключении бонуса',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Фильтруем плащи, которые уже куплены пользователем
|
||||
const availableCapes = storeCapes.filter(
|
||||
(storeCape) =>
|
||||
!userCapes.some((userCape) => userCape.cape_id === storeCape.id),
|
||||
);
|
||||
|
||||
const handleOpenCase = async (caseData: Case) => {
|
||||
if (!username) {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Не найдено имя игрока. Авторизуйтесь в лаунчере.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOnline || !playerServer) {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Для открытия кейсов необходимо находиться на сервере в игре.',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpening) return;
|
||||
|
||||
try {
|
||||
setIsOpening(true);
|
||||
|
||||
// 1. получаем полный кейс
|
||||
const fullCase = await fetchCase(caseData.id);
|
||||
const caseItems: CaseItem[] = fullCase.items || [];
|
||||
setSelectedCase(fullCase);
|
||||
|
||||
// 2. открываем кейс на бэке
|
||||
const result = await openCase(fullCase.id, username, playerServer.id);
|
||||
|
||||
// 3. сохраняем данные для рулетки
|
||||
setRouletteCaseItems(caseItems);
|
||||
setRouletteReward(result.reward);
|
||||
setRouletteOpen(true);
|
||||
|
||||
// 4. уведомление
|
||||
setNotification({
|
||||
open: true,
|
||||
message: result.message || 'Кейс открыт!',
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
setIsOpening(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при открытии кейса:', error);
|
||||
setNotification({
|
||||
open: true,
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Ошибка при открытии кейса',
|
||||
type: 'error',
|
||||
});
|
||||
setIsOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseNotification = () => {
|
||||
setNotification((prev) => ({ ...prev, open: false }));
|
||||
};
|
||||
|
||||
const handleCloseRoulette = () => {
|
||||
setRouletteOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: '2vw',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{(loading || onlineCheckLoading) && (
|
||||
<FullScreenLoader message="Загрузка магазина..." />
|
||||
)}
|
||||
|
||||
{!loading && !onlineCheckLoading && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '90%',
|
||||
height: '80%',
|
||||
gap: '2vw',
|
||||
overflow: 'auto',
|
||||
paddingTop: '3vh',
|
||||
paddingBottom: '10vh',
|
||||
paddingLeft: '5vw',
|
||||
paddingRight: '5vw',
|
||||
}}
|
||||
>
|
||||
{/* Блок прокачки */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
backgroundImage:
|
||||
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
Прокачка
|
||||
</Typography>
|
||||
|
||||
{bonusesLoading ? (
|
||||
<FullScreenLoader
|
||||
fullScreen={false}
|
||||
message="Загрузка прокачки..."
|
||||
/>
|
||||
) : bonusTypes.length > 0 ? (
|
||||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||||
{bonusTypes.map((bt) => {
|
||||
const userBonus = userBonuses.find(
|
||||
(ub) => ub.bonus_type_id === bt.id,
|
||||
);
|
||||
const owned = !!userBonus;
|
||||
|
||||
const level = owned ? userBonus!.level : 0;
|
||||
const effectValue = owned
|
||||
? userBonus!.effect_value
|
||||
: bt.base_effect_value;
|
||||
const nextEffectValue =
|
||||
owned && userBonus!.can_upgrade
|
||||
? bt.base_effect_value +
|
||||
userBonus!.level * bt.effect_increment
|
||||
: undefined;
|
||||
|
||||
const isActive = owned ? userBonus!.is_active : false;
|
||||
const isPermanent = owned
|
||||
? userBonus!.is_permanent
|
||||
: bt.duration === 0;
|
||||
|
||||
const cardId = owned ? userBonus!.id : bt.id;
|
||||
const processing = processingBonusIds.includes(cardId);
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={bt.id}>
|
||||
<BonusShopItem
|
||||
id={cardId}
|
||||
name={bt.name}
|
||||
description={bt.description}
|
||||
imageUrl={bt.image_url}
|
||||
level={level}
|
||||
effectValue={effectValue}
|
||||
nextEffectValue={nextEffectValue}
|
||||
price={bt.price}
|
||||
upgradePrice={bt.upgrade_price}
|
||||
canUpgrade={userBonus?.can_upgrade ?? false}
|
||||
mode={owned ? 'upgrade' : 'buy'}
|
||||
isActive={isActive}
|
||||
isPermanent={isPermanent}
|
||||
disabled={processing}
|
||||
onBuy={
|
||||
!owned ? () => handlePurchaseBonus(bt.id) : undefined
|
||||
}
|
||||
onUpgrade={
|
||||
owned
|
||||
? () => handleUpgradeBonus(userBonus!.id)
|
||||
: undefined
|
||||
}
|
||||
onToggleActive={
|
||||
owned
|
||||
? () => handleToggleBonusActivation(userBonus!.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography>Прокачка временно недоступна.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Блок кейсов */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 2,
|
||||
mb: 1,
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
backgroundImage:
|
||||
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
Кейсы
|
||||
</Typography>
|
||||
|
||||
{!isOnline && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
transition: 'transform 0.3s ease',
|
||||
width: '60%',
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderRadius: '2.5vw',
|
||||
fontSize: '0.8em',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
checkPlayerStatus(); // обновляем онлайн-статус
|
||||
loadCases(); // обновляем ТОЛЬКО кейсы
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!isOnline ? (
|
||||
<Typography variant="body1" color="error" sx={{ mb: 2 }}>
|
||||
Для открытия кейсов вам необходимо находиться на одном из
|
||||
серверов игры. Зайдите в игру и нажмите кнопку «Обновить».
|
||||
</Typography>
|
||||
) : casesLoading ? (
|
||||
<FullScreenLoader
|
||||
fullScreen={false}
|
||||
message="Загрузка кейсов..."
|
||||
/>
|
||||
) : cases.length > 0 ? (
|
||||
<Grid container spacing={2} sx={{ mb: 4 }}>
|
||||
{cases.map((c) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={c.id}>
|
||||
<ShopItem
|
||||
type="case"
|
||||
id={c.id}
|
||||
name={c.name}
|
||||
description={c.description}
|
||||
imageUrl={c.image_url}
|
||||
price={c.price}
|
||||
itemsCount={c.items_count}
|
||||
isOpening={isOpening && selectedCase?.id === c.id}
|
||||
disabled={!isOnline || isOpening}
|
||||
onClick={() => handleOpenCase(c)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography>Кейсы временно недоступны.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Блок плащей (как был) */}
|
||||
|
||||
{/* Блок плащей */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
backgroundImage:
|
||||
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
Плащи
|
||||
</Typography>
|
||||
|
||||
{availableCapes.length > 0 ? (
|
||||
<Grid container spacing={2}>
|
||||
{availableCapes.map((cape) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={cape.id}>
|
||||
<ShopItem
|
||||
type="cape"
|
||||
id={cape.id}
|
||||
name={cape.name}
|
||||
description={cape.description}
|
||||
imageUrl={cape.image_url}
|
||||
price={cape.price}
|
||||
disabled={false}
|
||||
playerSkinUrl={playerSkinUrl}
|
||||
onClick={() => handlePurchaseCape(cape.id)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography>У вас уже есть все доступные плащи!</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Компонент с анимацией рулетки */}
|
||||
<CaseRoulette
|
||||
open={rouletteOpen}
|
||||
onClose={handleCloseRoulette}
|
||||
caseName={selectedCase?.name}
|
||||
items={rouletteCaseItems}
|
||||
reward={rouletteReward}
|
||||
/>
|
||||
|
||||
{/* Уведомления */}
|
||||
<Snackbar
|
||||
open={notification.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleCloseNotification}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseNotification}
|
||||
severity={notification.type}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{notification.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
539
src/renderer/pages/VersionsExplorer.tsx
Normal file
539
src/renderer/pages/VersionsExplorer.tsx
Normal file
@ -0,0 +1,539 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Modal,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
} from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
|
||||
interface VersionCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
version: string;
|
||||
onSelect: (id: string) => void;
|
||||
isHovered: boolean;
|
||||
onHover: (id: string | null) => void;
|
||||
hoveredCardId: string | null;
|
||||
}
|
||||
|
||||
const gradientPrimary =
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||
|
||||
export const VersionCard: React.FC<VersionCardProps> = ({
|
||||
id,
|
||||
name,
|
||||
imageUrl, // пока не используется, но оставляем для будущего
|
||||
version,
|
||||
onSelect,
|
||||
isHovered,
|
||||
onHover,
|
||||
hoveredCardId,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
background:
|
||||
'radial-gradient(circle at top left, rgba(242,113,33,0.2), transparent 55%), rgba(10,10,20,0.95)',
|
||||
backdropFilter: 'blur(18px)',
|
||||
width: '35vw',
|
||||
height: '35vh',
|
||||
minWidth: 'unset',
|
||||
minHeight: 'unset',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '2.5vw',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
boxShadow: isHovered
|
||||
? '0 0 10px rgba(233,64,205,0.55)'
|
||||
: '0 14px 40px rgba(0, 0, 0, 0.6)',
|
||||
transition:
|
||||
'transform 0.35s ease, box-shadow 0.35s ease, border-color 0.35s ease',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
transform: isHovered ? 'scale(1.04)' : 'scale(1)',
|
||||
zIndex: isHovered ? 10 : 1,
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(242,113,33,0.8)',
|
||||
},
|
||||
}}
|
||||
onClick={() => onSelect(id)}
|
||||
onMouseEnter={() => onHover(id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1vh',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
gutterBottom
|
||||
variant="h5"
|
||||
component="div"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '2vw',
|
||||
backgroundImage: gradientPrimary,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: '1.1vw',
|
||||
}}
|
||||
>
|
||||
Версия {version}
|
||||
</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 [hoveredCardId, setHoveredCardId] = 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 | AvailableVersionInfo) => {
|
||||
const cfg: any = (version as any).config;
|
||||
|
||||
if (cfg && (cfg.downloadUrl || cfg.apiReleaseUrl)) {
|
||||
localStorage.setItem('selected_version_config', JSON.stringify(cfg));
|
||||
} else {
|
||||
localStorage.removeItem('selected_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={{
|
||||
background:
|
||||
'radial-gradient(circle at top left, rgba(233,64,205,0.3), rgba(10,10,20,0.95))',
|
||||
width: '35vw',
|
||||
height: '35vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '2.5vw',
|
||||
border: '1px dashed rgba(255,255,255,0.3)',
|
||||
boxShadow: '0 14px 40px rgba(0, 0, 0, 0.6)',
|
||||
transition: 'transform 0.35s ease, box-shadow 0.35s ease',
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
transform: hoveredCardId === 'add' ? 'scale(1.04)' : 'scale(1)',
|
||||
zIndex: hoveredCardId === 'add' ? 10 : 1,
|
||||
'&:hover': {
|
||||
boxShadow: '0 0 40px rgba(242,113,33,0.7)',
|
||||
borderStyle: 'solid',
|
||||
},
|
||||
}}
|
||||
onClick={handleAddVersion}
|
||||
onMouseEnter={() => setHoveredCardId('add')}
|
||||
onMouseLeave={() => setHoveredCardId(null)}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: '4vw', color: '#fff' }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#fff',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1.5vw',
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
Добавить версию
|
||||
</Typography>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '5vw',
|
||||
paddingRight: '5vw',
|
||||
position: 'relative',
|
||||
flexGrow: 1,
|
||||
height: 'calc(100vh - 64px)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Заголовок страницы в стиле Registration */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: '7vh',
|
||||
mb: '1vh',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '3vw',
|
||||
backgroundImage: gradientPrimary,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
Выбор версии клиента
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
Выберите установленную версию или добавьте новую сборку
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Глобальный фоновый слой (мягкий эффект) */}
|
||||
{/* <Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background:
|
||||
'radial-gradient(circle at top, rgba(242,113,33,0.12), transparent 55%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/> */}
|
||||
|
||||
{loading ? (
|
||||
<FullScreenLoader message="Загрузка ваших версий..." />
|
||||
) : (
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
justifyContent: 'center',
|
||||
alignContent: 'flex-start',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
pt: '3vh',
|
||||
}}
|
||||
>
|
||||
{installedVersions.length > 0 ? (
|
||||
installedVersions.map((version) => (
|
||||
<Grid
|
||||
key={version.id}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2vh',
|
||||
}}
|
||||
>
|
||||
<VersionCard
|
||||
id={version.id}
|
||||
name={version.name}
|
||||
imageUrl={
|
||||
version.imageUrl ||
|
||||
'https://via.placeholder.com/300x140?text=Minecraft'
|
||||
}
|
||||
version={version.version}
|
||||
onSelect={() => handleSelectVersion(version)}
|
||||
isHovered={hoveredCardId === version.id}
|
||||
onHover={setHoveredCardId}
|
||||
hoveredCardId={hoveredCardId}
|
||||
/>
|
||||
</Grid>
|
||||
))
|
||||
) : (
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2vh',
|
||||
}}
|
||||
>
|
||||
<AddVersionCard />
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{installedVersions.length > 0 && (
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2vh',
|
||||
}}
|
||||
>
|
||||
<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: 420,
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
background: 'linear-gradient(145deg, #000000 10%, #8A2387 100%)',
|
||||
border: '1px solid rgba(255,255,255,0.16)',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.85)',
|
||||
p: 4,
|
||||
borderRadius: '2.5vw',
|
||||
gap: '1.5vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backdropFilter: 'blur(18px)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="h2"
|
||||
sx={{
|
||||
color: '#fff',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Доступные версии для скачивания
|
||||
</Typography>
|
||||
|
||||
{availableVersions.length === 0 ? (
|
||||
<Typography sx={{ color: '#fff', mt: 2 }}>
|
||||
Загрузка доступных версий...
|
||||
</Typography>
|
||||
) : (
|
||||
<List sx={{ mt: 1 }}>
|
||||
{availableVersions.map((version) => (
|
||||
<ListItem
|
||||
key={version.id}
|
||||
sx={{
|
||||
borderRadius: '1vw',
|
||||
mb: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.35)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
cursor: 'pointer',
|
||||
transition:
|
||||
'background-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
|
||||
},
|
||||
}}
|
||||
onClick={() => handleSelectVersion(version)}
|
||||
>
|
||||
<ListItemText
|
||||
primary={version.name}
|
||||
secondary={version.version}
|
||||
primaryTypographyProps={{
|
||||
color: '#fff',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
}}
|
||||
/>
|
||||
{downloadLoading === version.id && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'rgba(255,255,255,0.7)' }}
|
||||
>
|
||||
Загрузка...
|
||||
</Typography>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleCloseModal}
|
||||
variant="contained"
|
||||
sx={{
|
||||
mt: 3,
|
||||
alignSelf: 'center',
|
||||
px: 6,
|
||||
py: 1.2,
|
||||
borderRadius: '2.5vw',
|
||||
background: gradientPrimary,
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1vw',
|
||||
textTransform: 'none',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.6)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
128
src/renderer/utils/playerOnlineCheck.ts
Normal file
128
src/renderer/utils/playerOnlineCheck.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
21
src/renderer/utils/serverTranslator.ts
Normal file
21
src/renderer/utils/serverTranslator.ts
Normal file
@ -0,0 +1,21 @@
|
||||
// src/renderer/utils/serverTranslator.ts
|
||||
import { Server } from '../api';
|
||||
|
||||
type ServerLike = Pick<Server, 'name'> | { name: string };
|
||||
|
||||
export const translateServer = (
|
||||
server: ServerLike | null | undefined,
|
||||
): string => {
|
||||
if (!server?.name) return '';
|
||||
|
||||
switch (server.name) {
|
||||
case 'Server minecraft.hub.popa-popa.ru':
|
||||
return 'Хаб';
|
||||
case 'Server survival.hub.popa-popa.ru':
|
||||
return 'Выживание';
|
||||
case 'Server minecraft.minigames.popa-popa.ru':
|
||||
return 'Миниигры';
|
||||
default:
|
||||
return server.name;
|
||||
}
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"useUnknownInCatchVariables": false,
|
||||
"incremental": true,
|
||||
"target": "es2022",
|
||||
"module": "node16",
|
||||
|
||||
Reference in New Issue
Block a user