Compare commits
18 Commits
main
...
942066ea76
Author | SHA1 | Date | |
---|---|---|---|
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,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,3 +27,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.
18
assets/images/heart.svg
Normal file
18
assets/images/heart.svg
Normal file
@ -0,0 +1,18 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" width="8" height="4" fill="#FF2D0F"/>
|
||||
<rect x="8" y="4" width="4" height="16" fill="#FF2D0F"/>
|
||||
<rect x="4" 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="16" y="16" width="4" height="4" 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: 994 B |
1230
package-lock.json
generated
1230
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
package.json
35
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,14 +42,15 @@
|
||||
"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"
|
||||
@ -102,17 +103,32 @@
|
||||
},
|
||||
"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/installer": "^6.1.0",
|
||||
"@xmcl/user": "^4.2.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",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.3.0"
|
||||
"react-router-dom": "^7.3.0",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"undici": "^7.11.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",
|
||||
@ -175,7 +191,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 +250,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": {
|
||||
|
90
src/main/auth-service.ts
Normal file
90
src/main/auth-service.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { YggdrasilClient, YggrasilAuthentication } from '@xmcl/user';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Ely.by сервер
|
||||
const ELY_BY_AUTH_SERVER = 'https://authserver.ely.by';
|
||||
|
||||
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 {
|
||||
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();
|
||||
});
|
||||
|
1135
src/main/minecraft-launcher.ts
Normal file
1135
src/main/minecraft-launcher.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,19 @@
|
||||
// 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';
|
||||
|
||||
const electronHandler = {
|
||||
ipcRenderer: {
|
||||
@ -21,6 +32,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,53 @@
|
||||
/*
|
||||
* @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;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: white;
|
||||
padding: 10px 20px;
|
||||
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;
|
||||
p {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: scale(1.05);
|
||||
opacity: 1;
|
||||
h1 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
h2 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
height: fit-content;
|
||||
width: fit-content;
|
||||
margin: 10px;
|
||||
h3 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
h4 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
.Hello {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
h5 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
@ -1,50 +1,135 @@
|
||||
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 MinecraftBackround from './components/MinecraftBackround';
|
||||
import { Notifier } from './components/Notifier';
|
||||
import { VersionsExplorer } from './pages/VersionsExplorer';
|
||||
|
||||
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);
|
||||
setIsAuthenticated(isValid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setIsAuthenticated(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки авторизации:', error);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const validateToken = async (token: string) => {
|
||||
try {
|
||||
// Используем IPC для валидации токена через main процесс
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'validate-token',
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// Если токен недействителен, очищаем сохраненные данные в 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 <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
// Просто используйте window.open без useNavigate
|
||||
const handleRegister = () => {
|
||||
window.open('https://account.ely.by/register', '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
<MinecraftBackround />
|
||||
<TopBar onRegister={handleRegister} />
|
||||
<Notifier />
|
||||
<Routes>
|
||||
<Route path="/" element={<Hello />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<VersionsExplorer />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/launch/:versionId"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<LaunchPage />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Box>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
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,
|
||||
CircularProgress,
|
||||
} 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';
|
||||
|
||||
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 <CircularProgress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Typography color="error">{error}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxHeight: '300px', overflow: 'auto' }}>
|
||||
{renderFileTree(files)}
|
||||
</Box>
|
||||
);
|
||||
}
|
39
src/renderer/components/Login/AuthForm.tsx
Normal file
39
src/renderer/components/Login/AuthForm.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { Box, Button, TextField, Typography } from '@mui/material';
|
||||
|
||||
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' }}>
|
||||
<TextField
|
||||
required
|
||||
name="username"
|
||||
label="Введите ник"
|
||||
variant="outlined"
|
||||
value={config.username}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
type="password"
|
||||
name="password"
|
||||
label="Введите пароль"
|
||||
variant="outlined"
|
||||
value={config.password}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<Button onClick={onLogin} variant="contained">
|
||||
Войти
|
||||
</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;
|
110
src/renderer/components/MinecraftBackround.tsx
Normal file
110
src/renderer/components/MinecraftBackround.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { Box } from '@mui/material';
|
||||
import heart from '../../../assets/images/heart.svg';
|
||||
|
||||
export default function MinecraftBackround() {
|
||||
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>
|
||||
);
|
||||
};
|
121
src/renderer/components/ServerStatus/ServerStatus.tsx
Normal file
121
src/renderer/components/ServerStatus/ServerStatus.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { Box, Typography, CircularProgress, 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.loading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : 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;
|
144
src/renderer/components/TopBar.tsx
Normal file
144
src/renderer/components/TopBar.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { Box, Button, Typography } from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import MinimizeIcon from '@mui/icons-material/Minimize';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
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; // Опционально, если нужен обработчик регистрации
|
||||
}
|
||||
|
||||
export default function TopBar({ onRegister }: TopBarProps) {
|
||||
// Получаем текущий путь
|
||||
const location = useLocation();
|
||||
const isLoginPage = location.pathname === '/login';
|
||||
const isLaunchPage = location.pathname.startsWith('/launch');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLaunchPage = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '50px',
|
||||
zIndex: 1000,
|
||||
width: '100%',
|
||||
WebkitAppRegion: 'drag',
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'space-between', // Всё содержимое справа
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
gap: '2vw',
|
||||
padding: '1em',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{isLaunchPage && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => handleLaunchPage()}
|
||||
sx={{
|
||||
width: '3em',
|
||||
height: '3em',
|
||||
borderRadius: '50%',
|
||||
border: 'unset',
|
||||
color: 'white',
|
||||
minWidth: 'unset',
|
||||
minHeight: 'unset',
|
||||
}}
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{/* Правая часть со всеми кнопками */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
gap: '2vw',
|
||||
padding: '1em',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Кнопка регистрации, если на странице логина */}
|
||||
{isLoginPage && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => onRegister && onRegister()}
|
||||
sx={{
|
||||
width: '10em',
|
||||
height: '3em',
|
||||
borderRadius: '1.5vw',
|
||||
color: 'white',
|
||||
backgroundImage: 'linear-gradient(to right, #7BB8FF, #FFB7ED)',
|
||||
border: 'unset',
|
||||
'&:hover': {
|
||||
backgroundImage: 'linear-gradient(to right, #6AA8EE, #EEA7DD)',
|
||||
},
|
||||
boxShadow: '0.5em 0.5em 0.5em 0px #00000040 inset',
|
||||
}}
|
||||
>
|
||||
Регистрация
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Кнопки управления окном */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.electron.ipcRenderer.invoke('minimize-app');
|
||||
}}
|
||||
sx={{
|
||||
minWidth: 'unset',
|
||||
minHeight: 'unset',
|
||||
width: '3em',
|
||||
height: '3em',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<MinimizeIcon sx={{ color: 'white' }} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.electron.ipcRenderer.invoke('close-app');
|
||||
}}
|
||||
sx={{
|
||||
minWidth: 'unset',
|
||||
minHeight: 'unset',
|
||||
width: '3em',
|
||||
height: '3em',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<CloseIcon 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>
|
||||
|
433
src/renderer/pages/LaunchPage.tsx
Normal file
433
src/renderer/pages/LaunchPage.tsx
Normal file
@ -0,0 +1,433 @@
|
||||
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 [downloadProgress, setDownloadProgress] = 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 handleOpen = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
if (!savedConfig || !JSON.parse(savedConfig).accessToken) {
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
const progressListener = (...args: unknown[]) => {
|
||||
const progress = args[0] as number;
|
||||
setDownloadProgress(progress);
|
||||
setBuffer(Math.min(progress + 10, 100));
|
||||
};
|
||||
|
||||
const statusListener = (...args: unknown[]) => {
|
||||
const status = args[0] as { step: string; message: string };
|
||||
setInstallStep(status.step);
|
||||
setInstallMessage(status.message);
|
||||
};
|
||||
|
||||
window.electron.ipcRenderer.on('download-progress', progressListener);
|
||||
window.electron.ipcRenderer.on('installation-status', statusListener);
|
||||
|
||||
return () => {
|
||||
// Удаляем только конкретных слушателей, а не всех
|
||||
// Это безопаснее, чем removeAllListeners
|
||||
const cleanup = window.electron.ipcRenderer.on;
|
||||
if (typeof cleanup === 'function') {
|
||||
cleanup('download-progress', progressListener);
|
||||
cleanup('installation-status', statusListener);
|
||||
}
|
||||
// Удаляем использование 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);
|
||||
setVersionConfig(parsedConfig);
|
||||
|
||||
// Устанавливаем значения памяти и preserveFiles из конфигурации
|
||||
setConfig({
|
||||
memory: parsedConfig.memory || 4096,
|
||||
preserveFiles: parsedConfig.preserveFiles || [],
|
||||
});
|
||||
|
||||
// Очищаем localStorage
|
||||
localStorage.removeItem('selected_version_config');
|
||||
return;
|
||||
}
|
||||
|
||||
// Если нет в localStorage, запрашиваем с сервера
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'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);
|
||||
setDownloadProgress(0);
|
||||
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 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={downloadProgress}
|
||||
valueBuffer={buffer}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 35 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'white' }}
|
||||
>{`${Math.round(downloadProgress)}%`}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: '1vw',
|
||||
width: '100%', // родитель занимает всю ширину
|
||||
}}
|
||||
>
|
||||
{/* Первая кнопка — растягивается на всё доступное пространство */}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleLaunchMinecraft}
|
||||
sx={{
|
||||
flexGrow: 1, // занимает всё свободное место
|
||||
width: 'auto', // ширина подстраивается
|
||||
borderRadius: '3vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
background: 'linear-gradient(90deg, #3B96FF 0%, #FFB7ED 100%)',
|
||||
}}
|
||||
>
|
||||
Запустить Minecraft
|
||||
</Button>
|
||||
|
||||
{/* Вторая кнопка — квадратная, фиксированного размера (ширина = высоте) */}
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{
|
||||
flexShrink: 0, // не сжимается
|
||||
aspectRatio: '1', // ширина = высоте
|
||||
backgroundColor: 'grey',
|
||||
borderRadius: '3vw',
|
||||
minHeight: 'unset',
|
||||
minWidth: 'unset',
|
||||
height: '100%', // занимает полную высоту родителя
|
||||
}}
|
||||
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;
|
116
src/renderer/pages/Login.tsx
Normal file
116
src/renderer/pages/Login.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
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';
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
const { config, setConfig, saveConfig, handleInputChange } = useConfig();
|
||||
const { status, validateSession, refreshSession, authenticateWithElyBy } =
|
||||
useAuth();
|
||||
|
||||
const authorization = async () => {
|
||||
console.log('Начинаем процесс авторизации...');
|
||||
|
||||
if (!config.username.trim()) {
|
||||
console.log('Ошибка: не указан никнейм');
|
||||
alert('Введите никнейм!');
|
||||
return;
|
||||
}
|
||||
|
||||
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('Авторизация успешно завершена');
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
console.log(`ОШИБКА при авторизации: ${error.message}`);
|
||||
// Очищаем недействительные токены при ошибке
|
||||
saveConfig({
|
||||
accessToken: '',
|
||||
clientToken: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PopaPopa />
|
||||
<AuthForm
|
||||
config={config}
|
||||
handleInputChange={handleInputChange}
|
||||
onLogin={authorization}
|
||||
/>
|
||||
<MemorySlider
|
||||
memory={config.memory}
|
||||
onChange={(e, value) => {
|
||||
setConfig((prev) => ({ ...prev, memory: value as number }));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
394
src/renderer/pages/VersionsExplorer.tsx
Normal file
394
src/renderer/pages/VersionsExplorer.tsx
Normal file
@ -0,0 +1,394 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Modal,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
|
||||
interface VersionCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
version: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const VersionCard: React.FC<VersionCardProps> = ({
|
||||
id,
|
||||
name,
|
||||
imageUrl,
|
||||
version,
|
||||
onSelect,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: 'rgba(30, 30, 50, 0.8)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
width: '35vw',
|
||||
height: '35vh',
|
||||
minWidth: 'unset',
|
||||
minHeight: 'unset',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'transform 0.3s, box-shadow 0.3s',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => onSelect(id)}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '10%',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
gutterBottom
|
||||
variant="h5"
|
||||
component="div"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
fontSize: '1.5rem',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface VersionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
imageUrl?: string;
|
||||
config?: {
|
||||
downloadUrl: string;
|
||||
apiReleaseUrl: string;
|
||||
versionFileName: string;
|
||||
packName: string;
|
||||
memory: number;
|
||||
baseVersion: string;
|
||||
serverIp: string;
|
||||
fabricVersion: string;
|
||||
preserveFiles: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface AvailableVersionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
imageUrl?: string;
|
||||
config: {
|
||||
downloadUrl: string;
|
||||
apiReleaseUrl: string;
|
||||
versionFileName: string;
|
||||
packName: string;
|
||||
memory: number;
|
||||
baseVersion: string;
|
||||
serverIp: string;
|
||||
fabricVersion: string;
|
||||
preserveFiles: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// В компоненте VersionsExplorer
|
||||
export const VersionsExplorer = () => {
|
||||
const [installedVersions, setInstalledVersions] = useState<VersionInfo[]>([]);
|
||||
const [availableVersions, setAvailableVersions] = useState<
|
||||
AvailableVersionInfo[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [downloadLoading, setDownloadLoading] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVersions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Получаем список установленных версий через IPC
|
||||
const installedResult = await window.electron.ipcRenderer.invoke(
|
||||
'get-installed-versions',
|
||||
);
|
||||
if (installedResult.success) {
|
||||
setInstalledVersions(installedResult.versions);
|
||||
}
|
||||
|
||||
// Получаем доступные версии с GitHub Gist
|
||||
const availableResult = await window.electron.ipcRenderer.invoke(
|
||||
'get-available-versions',
|
||||
{
|
||||
gistUrl:
|
||||
'https://gist.githubusercontent.com/DIKER0K/06cd12fb3a4d08b1f0f8c763a7d05e06/raw/versions.json',
|
||||
},
|
||||
);
|
||||
if (availableResult.success) {
|
||||
setAvailableVersions(availableResult.versions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке версий:', error);
|
||||
// Можно добавить обработку ошибки, например показать уведомление
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVersions();
|
||||
}, []);
|
||||
|
||||
const handleSelectVersion = (version: VersionInfo) => {
|
||||
localStorage.setItem(
|
||||
'selected_version_config',
|
||||
JSON.stringify(version.config || {}),
|
||||
);
|
||||
navigate(`/launch/${version.id}`);
|
||||
};
|
||||
|
||||
const handleAddVersion = () => {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const handleDownloadVersion = async (version: AvailableVersionInfo) => {
|
||||
try {
|
||||
setDownloadLoading(version.id);
|
||||
|
||||
// Скачивание и установка выбранной версии
|
||||
const downloadResult = await window.electron.ipcRenderer.invoke(
|
||||
'download-and-extract',
|
||||
{
|
||||
downloadUrl: version.config.downloadUrl,
|
||||
apiReleaseUrl: version.config.apiReleaseUrl,
|
||||
versionFileName: version.config.versionFileName,
|
||||
packName: version.id,
|
||||
preserveFiles: version.config.preserveFiles || [],
|
||||
},
|
||||
);
|
||||
|
||||
if (downloadResult?.success) {
|
||||
// Добавляем скачанную версию в список установленных
|
||||
setInstalledVersions((prev) => [...prev, version]);
|
||||
setModalOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при скачивании версии ${version.id}:`, error);
|
||||
} finally {
|
||||
setDownloadLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Карточка добавления новой версии
|
||||
const AddVersionCard = () => (
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: 'rgba(30, 30, 50, 0.8)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
width: '35vw',
|
||||
height: '35vh',
|
||||
minWidth: 'unset',
|
||||
minHeight: 'unset',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'transform 0.3s, box-shadow 0.3s',
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={handleAddVersion}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: 60, color: '#fff' }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
Добавить
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
версию
|
||||
</Typography>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '5vw',
|
||||
paddingRight: '5vw',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" my={5}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
sx={{
|
||||
width: '100%',
|
||||
overflowY: 'auto',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Показываем установленные версии или дефолтную, если она есть */}
|
||||
{installedVersions.length > 0 ? (
|
||||
installedVersions.map((version) => (
|
||||
<Grid
|
||||
key={version.id}
|
||||
size={{ xs: 'auto', sm: 'auto', md: 'auto' }}
|
||||
>
|
||||
<VersionCard
|
||||
id={version.id}
|
||||
name={version.name}
|
||||
imageUrl={
|
||||
version.imageUrl ||
|
||||
'https://via.placeholder.com/300x140?text=Minecraft'
|
||||
}
|
||||
version={version.version}
|
||||
onSelect={() => handleSelectVersion(version)}
|
||||
/>
|
||||
</Grid>
|
||||
))
|
||||
) : (
|
||||
// Если нет ни одной версии, показываем карточку добавления
|
||||
<Grid size={{ xs: 'auto', sm: 'auto', md: 'auto' }}>
|
||||
<AddVersionCard />
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Всегда добавляем карточку для добавления новых версий */}
|
||||
{installedVersions.length > 0 && (
|
||||
<Grid size={{ xs: 'auto', sm: 'auto', md: 'auto' }}>
|
||||
<AddVersionCard />
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Модальное окно для выбора версии для скачивания */}
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
onClose={handleCloseModal}
|
||||
aria-labelledby="modal-versions"
|
||||
aria-describedby="modal-available-versions"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
background: 'linear-gradient(45deg, #000000 10%, #3b4187 184.73%)',
|
||||
border: '2px solid #000',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
borderRadius: '3vw',
|
||||
gap: '1vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="h2" sx={{ color: '#fff' }}>
|
||||
Доступные версии для скачивания
|
||||
</Typography>
|
||||
|
||||
{availableVersions.length === 0 ? (
|
||||
<Typography sx={{ color: '#fff', mt: 2 }}>
|
||||
Загрузка доступных версий...
|
||||
</Typography>
|
||||
) : (
|
||||
<List sx={{ mt: 2 }}>
|
||||
{availableVersions.map((version) => (
|
||||
<ListItem
|
||||
key={version.id}
|
||||
sx={{
|
||||
borderRadius: '8px',
|
||||
mb: 1,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
}}
|
||||
onClick={() => handleSelectVersion(version)}
|
||||
>
|
||||
<ListItemText
|
||||
primary={version.name}
|
||||
secondary={version.version}
|
||||
primaryTypographyProps={{ color: '#fff' }}
|
||||
secondaryTypographyProps={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleCloseModal}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mt: 3,
|
||||
alignSelf: 'center',
|
||||
borderColor: '#fff',
|
||||
color: '#fff',
|
||||
'&:hover': {
|
||||
borderColor: '#ccc',
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"useUnknownInCatchVariables": false,
|
||||
"incremental": true,
|
||||
"target": "es2022",
|
||||
"module": "node16",
|
||||
|
Reference in New Issue
Block a user