Compare commits
86 Commits
dev
...
6adc64dab8
| Author | SHA1 | Date | |
|---|---|---|---|
| 6adc64dab8 | |||
| 11a203cb8f | |||
| ff87c9d4a5 | |||
| e93379ff12 | |||
| 28fc3ab0fb | |||
| 645de4248e | |||
| ae4a67dcdf | |||
| de616ee8ac | |||
| d1e64382a4 | |||
| 41c1ae3357 | |||
| 62fe32ea99 | |||
| f6e295d157 | |||
| 1900a9d1e6 | |||
| ca8ac8e880 | |||
| abb45c3838 | |||
| d9a3a1cd1f | |||
| 74a3e3c7cf | |||
| 7d7136bac9 | |||
| 9a0daa26ca | |||
| 712ae70e2a | |||
| 226f5c1393 | |||
| eabc54680f | |||
| bfb5a8ae6d | |||
| 5e5f1aaa0a | |||
| 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 |
@ -112,6 +112,13 @@ const configuration: webpack.Configuration = {
|
|||||||
'file-loader',
|
'file-loader',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /\.(mp3|wav|ogg)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
generator: {
|
||||||
|
filename: 'assets/sounds/[name][ext]',
|
||||||
|
},
|
||||||
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
@ -88,6 +88,13 @@ const configuration: webpack.Configuration = {
|
|||||||
'file-loader',
|
'file-loader',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /\.(mp3|wav|ogg)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
generator: {
|
||||||
|
filename: 'assets/sounds/[name][ext]',
|
||||||
|
},
|
||||||
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
|||||||
|
public/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
38
README.md
38
README.md
@ -157,3 +157,41 @@ MIT © [Electron React Boilerplate](https://github.com/electron-react-boilerplat
|
|||||||
[github-tag-url]: https://github.com/electron-react-boilerplate/electron-react-boilerplate/releases/latest
|
[github-tag-url]: https://github.com/electron-react-boilerplate/electron-react-boilerplate/releases/latest
|
||||||
[stackoverflow-img]: https://img.shields.io/badge/stackoverflow-electron_react_boilerplate-blue.svg
|
[stackoverflow-img]: https://img.shields.io/badge/stackoverflow-electron_react_boilerplate-blue.svg
|
||||||
[stackoverflow-url]: https://stackoverflow.com/questions/tagged/electron-react-boilerplate
|
[stackoverflow-url]: https://stackoverflow.com/questions/tagged/electron-react-boilerplate
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Для использования CustomNotification:
|
||||||
|
|
||||||
|
# IMPORTS
|
||||||
|
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||||
|
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||||
|
import { getNotificationPosition } from '../utils/settings';
|
||||||
|
|
||||||
|
# STATE
|
||||||
|
const [notifOpen, setNotifOpen] = useState(false);
|
||||||
|
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||||||
|
const [notifSeverity, setNotifSeverity] = useState<
|
||||||
|
'success' | 'info' | 'warning' | 'error'
|
||||||
|
>('info');
|
||||||
|
|
||||||
|
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
# ВМЕСТО setNotification
|
||||||
|
setNotifMsg('Ошибка при загрузке прокачки!'); // string
|
||||||
|
setNotifSeverity('error'); // 'success' || 'info' || 'warning' || 'error'
|
||||||
|
setNotifPos(getNotificationPosition()); // top || bottom & center || right || left
|
||||||
|
setNotifOpen(true); // Не изменять
|
||||||
|
|
||||||
|
# СРАЗУ ПОСЛЕ ПЕРВОГО <Box>
|
||||||
|
|
||||||
|
<CustomNotification
|
||||||
|
open={notifOpen}
|
||||||
|
message={notifMsg}
|
||||||
|
severity={notifSeverity}
|
||||||
|
position={notifPos}
|
||||||
|
onClose={() => setNotifOpen(false)}
|
||||||
|
autoHideDuration={2500}
|
||||||
|
/>
|
||||||
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 |
@ -1,11 +1,23 @@
|
|||||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="4" width="8" height="4" fill="#FF2D0F"/>
|
<rect x="0" y="12" width="28" height="4" fill="#BD2211"/>
|
||||||
<rect x="8" y="4" width="4" height="16" fill="#FF2D0F"/>
|
<rect x="4" y="16" width="20" height="4" fill="#BD2211"/>
|
||||||
<rect x="4" y="8" width="4" height="8" fill="#FF2D0F"/>
|
<rect x="8" y="20" width="12" height="4" fill="#BD2211"/>
|
||||||
|
<rect x="12" y="24" width="4" height="4" fill="#BD2211"/>
|
||||||
|
<rect x="4" width="8" height="4.5" fill="#FF2D0F"/>
|
||||||
|
<rect x="16" width="8" height="4.5" fill="#FF2D0F"/>
|
||||||
|
<rect x="0" y="4" width="28" height="8" fill="#FF2D0F"/>
|
||||||
|
<rect x="4" y="8" width="20" height="8" fill="#FF2D0F"/>
|
||||||
|
<rect x="8" y="12" width="12" height="8" fill="#FF2D0F"/>
|
||||||
|
<rect x="12" y="16" width="4" height="8" fill="#FF2D0F"/>
|
||||||
|
<rect x="4" y="4" width="4" height="4" fill="#FFCAC8"/>
|
||||||
|
<!-- <rect x="7" y="4" width="6" height="16" fill="#FF2D0F"/>
|
||||||
|
<rect x="6" y="3" width="6" height="16" fill="#FF2D0F"/>
|
||||||
|
<rect x="3" y="8" width="4" height="8" fill="#FF2D0F"/>
|
||||||
<rect y="4" width="4" height="8" fill="#FF2D0F"/>
|
<rect y="4" width="4" height="8" fill="#FF2D0F"/>
|
||||||
<rect x="24" y="4" width="4" height="8" fill="#FF2D0F"/>
|
<rect x="24" y="4" width="4" height="8" fill="#FF2D0F"/>
|
||||||
<rect x="16" width="8" height="16" fill="#FF2D0F"/>
|
<rect x="16" width="8" height="16" fill="#FF2D0F"/>
|
||||||
<rect x="16" y="16" width="4" height="4" fill="#FF2D0F"/>
|
<rect x="20" y="4" width="8" height="12" fill="#FF2D0F"/>
|
||||||
|
<rect x="15" y="4" width="5" height="16" fill="#FF2D0F"/>
|
||||||
<rect x="24" y="12" width="4" height="4" fill="#BD2211"/>
|
<rect x="24" y="12" width="4" height="4" fill="#BD2211"/>
|
||||||
<rect x="20" y="16" width="4" height="4" fill="#BD2211"/>
|
<rect x="20" y="16" width="4" height="4" fill="#BD2211"/>
|
||||||
<rect x="16" y="20" width="4" height="4" fill="#BD2211"/>
|
<rect x="16" y="20" width="4" height="4" fill="#BD2211"/>
|
||||||
@ -14,5 +26,5 @@
|
|||||||
<rect x="4" y="16" width="4" height="4" fill="#BD2211"/>
|
<rect x="4" y="16" width="4" height="4" fill="#BD2211"/>
|
||||||
<rect x="4" y="4" width="4" height="4" fill="#FFCAC8"/>
|
<rect x="4" y="4" width="4" height="4" fill="#FFCAC8"/>
|
||||||
<rect y="12" width="4" height="4" fill="#BD2211"/>
|
<rect y="12" width="4" height="4" fill="#BD2211"/>
|
||||||
<rect x="12" y="4" width="4" height="20" fill="#FF2D0F"/>
|
<rect x="12" y="4" width="4" height="20" fill="#FF2D0F"/> -->
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 994 B After Width: | Height: | Size: 1.7 KiB |
8629
package-lock.json
generated
8629
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -55,6 +55,9 @@
|
|||||||
"browserslist": [
|
"browserslist": [
|
||||||
"extends browserslist-config-erb"
|
"extends browserslist-config-erb"
|
||||||
],
|
],
|
||||||
|
"overrides": {
|
||||||
|
"undici": "6.10.2"
|
||||||
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"overrides": [
|
"overrides": [
|
||||||
@ -108,19 +111,26 @@
|
|||||||
"@mui/icons-material": "^7.2.0",
|
"@mui/icons-material": "^7.2.0",
|
||||||
"@mui/material": "^7.2.0",
|
"@mui/material": "^7.2.0",
|
||||||
"@xmcl/core": "^2.14.1",
|
"@xmcl/core": "^2.14.1",
|
||||||
"@xmcl/installer": "^6.1.0",
|
"@xmcl/file-transfer": "^2.0.3",
|
||||||
|
"@xmcl/installer": "^6.1.2",
|
||||||
|
"@xmcl/resourcepack": "^1.2.4",
|
||||||
"@xmcl/user": "^4.2.0",
|
"@xmcl/user": "^4.2.0",
|
||||||
|
"easymde": "^2.20.0",
|
||||||
"electron-debug": "^4.1.0",
|
"electron-debug": "^4.1.0",
|
||||||
"electron-log": "^5.3.2",
|
"electron-log": "^5.3.2",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
"find-java-home": "^2.0.0",
|
"find-java-home": "^2.0.0",
|
||||||
"https-browserify": "^1.0.0",
|
"https-browserify": "^1.0.0",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
|
"qr-code-styling": "^1.9.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.3.0",
|
"react-router-dom": "^7.3.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"skinview3d": "^3.4.1",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"undici": "^7.11.0",
|
"three": "^0.178.0",
|
||||||
"util": "^0.12.5",
|
"util": "^0.12.5",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
@ -137,6 +147,7 @@
|
|||||||
"@types/react": "^19.0.11",
|
"@types/react": "^19.0.11",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@types/react-test-renderer": "^19.0.0",
|
"@types/react-test-renderer": "^19.0.0",
|
||||||
|
"@types/three": "^0.178.1",
|
||||||
"@types/webpack-bundle-analyzer": "^4.7.0",
|
"@types/webpack-bundle-analyzer": "^4.7.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"@typescript-eslint/parser": "^8.26.1",
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { YggdrasilClient, YggrasilAuthentication } from '@xmcl/user';
|
import { YggdrasilClient, YggrasilAuthentication } from '@xmcl/user';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { API_BASE_URL } from '../renderer/api';
|
||||||
|
|
||||||
// Ely.by сервер
|
// Ely.by сервер
|
||||||
const ELY_BY_AUTH_SERVER = 'https://authserver.ely.by';
|
const ELY_BY_AUTH_SERVER = API_BASE_URL;
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private client: YggdrasilClient;
|
private client: YggdrasilClient;
|
||||||
@ -49,6 +50,7 @@ export class AuthService {
|
|||||||
|
|
||||||
async validate(accessToken: string, clientToken: string): Promise<boolean> {
|
async validate(accessToken: string, clientToken: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
console.log(accessToken, clientToken);
|
||||||
const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/validate`, {
|
const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/validate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -119,7 +119,7 @@ const createWindow = async () => {
|
|||||||
width: 1024,
|
width: 1024,
|
||||||
height: 850,
|
height: 850,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
resizable: false,
|
resizable: true,
|
||||||
frame: false,
|
frame: false,
|
||||||
icon: getAssetPath('icon.png'),
|
icon: getAssetPath('icon.png'),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
|
|||||||
@ -14,8 +14,63 @@ import {
|
|||||||
installTask,
|
installTask,
|
||||||
installDependenciesTask,
|
installDependenciesTask,
|
||||||
} from '@xmcl/installer';
|
} from '@xmcl/installer';
|
||||||
|
import { Dispatcher, Agent } from 'undici';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { AuthService } from './auth-service';
|
import { AuthService } from './auth-service';
|
||||||
|
import { API_BASE_URL } from '../renderer/api';
|
||||||
|
|
||||||
|
// const CDN = 'https://cdn.minecraft.popa-popa.ru';
|
||||||
|
|
||||||
|
// const DOWNLOAD_OPTIONS = {
|
||||||
|
// // assets (objects/)
|
||||||
|
// assetsHost: `${CDN}/assets/objects`,
|
||||||
|
|
||||||
|
// // библиотеки (jar'ы)
|
||||||
|
// libraryHost(library: { path: any }) {
|
||||||
|
// return `${CDN}/libraries/${library.path}`;
|
||||||
|
// },
|
||||||
|
|
||||||
|
// assetsIndexUrl: (version: any) =>
|
||||||
|
// `${CDN}/assets/indexes/${version.assetIndex.id}.json`,
|
||||||
|
|
||||||
|
// // версии
|
||||||
|
// json(versionInfo: { id: any }) {
|
||||||
|
// return `${CDN}/versions/${versionInfo.id}/${versionInfo.id}.json`;
|
||||||
|
// },
|
||||||
|
// client(resolved: { id: any }) {
|
||||||
|
// return `${CDN}/versions/${resolved.id}/${resolved.id}.jar`;
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
|
||||||
|
const INSTALL_PHASES = [
|
||||||
|
{ id: 'download', weight: 0.25 }, // 25% — скачивание сборки
|
||||||
|
{ id: 'minecraft-install', weight: 0.3 }, // 30% — ваниль
|
||||||
|
{ id: 'fabric-install', weight: 0.15 }, // 15% — Fabric
|
||||||
|
{ id: 'dependencies', weight: 0.25 }, // 25% — библиотеки/ресурсы
|
||||||
|
{ id: 'launch', weight: 0.05 }, // 5% — запуск
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type InstallPhaseId = (typeof INSTALL_PHASES)[number]['id'];
|
||||||
|
|
||||||
|
function getGlobalProgress(phaseId: InstallPhaseId, localProgress01: number) {
|
||||||
|
let offset = 0;
|
||||||
|
let weight = 0;
|
||||||
|
|
||||||
|
for (const phase of INSTALL_PHASES) {
|
||||||
|
if (phase.id === phaseId) {
|
||||||
|
weight = phase.weight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += phase.weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!weight) return 100;
|
||||||
|
|
||||||
|
const clampedLocal = Math.max(0, Math.min(1, localProgress01));
|
||||||
|
const global = (offset + clampedLocal * weight) * 100;
|
||||||
|
|
||||||
|
return Math.round(Math.max(0, Math.min(global, 100)));
|
||||||
|
}
|
||||||
|
|
||||||
// Константы
|
// Константы
|
||||||
const AUTHLIB_INJECTOR_FILENAME = 'authlib-injector-1.2.5.jar';
|
const AUTHLIB_INJECTOR_FILENAME = 'authlib-injector-1.2.5.jar';
|
||||||
@ -24,6 +79,13 @@ const MCSTATUS_API_URL = 'https://api.mcstatus.io/v2/status/java/';
|
|||||||
// Создаем экземпляр сервиса аутентификации
|
// Создаем экземпляр сервиса аутентификации
|
||||||
const authService = new AuthService();
|
const authService = new AuthService();
|
||||||
|
|
||||||
|
const agent = new Agent({
|
||||||
|
connections: 16, // максимум 16 одновременных соединений (скачиваний)
|
||||||
|
// тут можно задать и другие параметры при необходимости
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentMinecraftProcess: any | null = null;
|
||||||
|
|
||||||
// Модифицированная функция для получения последней версии релиза с произвольного URL
|
// Модифицированная функция для получения последней версии релиза с произвольного URL
|
||||||
export async function getLatestReleaseVersion(apiUrl: string): Promise<string> {
|
export async function getLatestReleaseVersion(apiUrl: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
@ -280,7 +342,7 @@ export async function findJava(): Promise<string> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Предпочитаем Java 21 или 17 для совместимости с authlib-injector
|
// Предпочитаем Java 21 или 17 для совместимости с authlib-injector
|
||||||
const preferredVersions = [21, 17, 11];
|
const preferredVersions = [24, 21, 17, 11];
|
||||||
|
|
||||||
for (const preferredVersion of preferredVersions) {
|
for (const preferredVersion of preferredVersions) {
|
||||||
const preferred = javaVersions.find(
|
const preferred = javaVersions.find(
|
||||||
@ -389,6 +451,9 @@ export function initMinecraftHandlers() {
|
|||||||
// Скачиваем файл
|
// Скачиваем файл
|
||||||
await downloadFile(downloadUrl, zipPath, (progress) => {
|
await downloadFile(downloadUrl, zipPath, (progress) => {
|
||||||
event.sender.send('download-progress', progress);
|
event.sender.send('download-progress', progress);
|
||||||
|
|
||||||
|
const global = getGlobalProgress('download', progress / 100);
|
||||||
|
event.sender.send('overall-progress', global);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Проверяем архив
|
// Проверяем архив
|
||||||
@ -479,264 +544,302 @@ export function initMinecraftHandlers() {
|
|||||||
username,
|
username,
|
||||||
memory = 4096,
|
memory = 4096,
|
||||||
baseVersion = '1.21.4',
|
baseVersion = '1.21.4',
|
||||||
fabricVersion = 'fabric0.16.14',
|
fabricVersion = '0.16.14',
|
||||||
packName = 'Comfort', // Название основной сборки
|
packName = 'Comfort', // имя сборки (папка с модами)
|
||||||
versionToLaunchOverride = '', // Возможность переопределить версию для запуска
|
versionToLaunchOverride = '', // переопределение версии для запуска (например, 1.21.10 для ванили)
|
||||||
serverIp = 'popa-popa.ru',
|
serverIp = 'popa-popa.ru',
|
||||||
serverPort, // Добавляем опциональный порт без значения по умолчанию
|
serverPort,
|
||||||
|
isVanillaVersion = false,
|
||||||
} = gameConfig || {};
|
} = gameConfig || {};
|
||||||
|
|
||||||
const appPath = path.dirname(app.getPath('exe'));
|
const appPath = path.dirname(app.getPath('exe'));
|
||||||
const minecraftDir = path.join(appPath, '.minecraft');
|
const minecraftDir = path.join(appPath, '.minecraft');
|
||||||
const versionsDir = path.join(minecraftDir, 'versions');
|
const versionsDir = path.join(minecraftDir, 'versions');
|
||||||
|
fs.mkdirSync(versionsDir, { recursive: true });
|
||||||
|
|
||||||
|
// gamePath:
|
||||||
|
// - ваниль → .minecraft
|
||||||
|
// - модпак → .minecraft/versions/Comfort (или другое packName)
|
||||||
|
const packDir = isVanillaVersion
|
||||||
|
? minecraftDir
|
||||||
|
: path.join(versionsDir, packName);
|
||||||
|
if (!fs.existsSync(packDir)) {
|
||||||
|
fs.mkdirSync(packDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Определяем версию для запуска
|
|
||||||
const versionsContents = fs.existsSync(versionsDir)
|
const versionsContents = fs.existsSync(versionsDir)
|
||||||
? fs.readdirSync(versionsDir)
|
? fs.readdirSync(versionsDir)
|
||||||
: [];
|
: [];
|
||||||
console.log('Доступные версии:', versionsContents);
|
console.log('Доступные версии:', versionsContents);
|
||||||
|
|
||||||
// Найти версию пакета, Fabric или базовую версию
|
// --- Определяем базовую / фактическую версию ---
|
||||||
let versionToLaunch = versionToLaunchOverride;
|
let effectiveBaseVersion = baseVersion;
|
||||||
|
|
||||||
|
// Для ванили считаем базовой именно ту, которую хотим запустить
|
||||||
|
if (isVanillaVersion && versionToLaunchOverride) {
|
||||||
|
effectiveBaseVersion = versionToLaunchOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
let versionToLaunch: string | undefined =
|
||||||
|
versionToLaunchOverride || undefined;
|
||||||
|
|
||||||
if (!versionToLaunch) {
|
if (!versionToLaunch) {
|
||||||
if (
|
if (isVanillaVersion) {
|
||||||
versionsContents.includes(`${baseVersion}-fabric${fabricVersion}`)
|
// Ваниль — запускаем baseVersion (или override)
|
||||||
) {
|
versionToLaunch = effectiveBaseVersion;
|
||||||
versionToLaunch = `${baseVersion}-fabric${fabricVersion}`;
|
|
||||||
} else if (versionsContents.includes(packName)) {
|
|
||||||
versionToLaunch = packName;
|
|
||||||
} else {
|
} else {
|
||||||
versionToLaunch = baseVersion;
|
// Модпак — запускаем именно fabric-версию
|
||||||
|
const fabricId = `${effectiveBaseVersion}-fabric${fabricVersion}`;
|
||||||
|
if (versionsContents.includes(fabricId)) {
|
||||||
|
versionToLaunch = fabricId;
|
||||||
|
} else {
|
||||||
|
// даже если папки нет — installFabric её создаст
|
||||||
|
versionToLaunch = fabricId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Запускаем версию:', versionToLaunch);
|
console.log('isVanillaVersion:', isVanillaVersion);
|
||||||
|
console.log('baseVersion:', baseVersion);
|
||||||
|
console.log('effectiveBaseVersion:', effectiveBaseVersion);
|
||||||
|
console.log('fabricVersion:', fabricVersion);
|
||||||
|
console.log('versionToLaunch:', versionToLaunch);
|
||||||
|
console.log('packDir:', packDir);
|
||||||
|
|
||||||
// Находим путь к Java
|
// --- Поиск Java ---
|
||||||
event.sender.send('installation-status', {
|
event.sender.send('installation-status', {
|
||||||
step: 'java',
|
step: 'java',
|
||||||
message: 'Поиск Java...',
|
message: 'Поиск Java...',
|
||||||
});
|
});
|
||||||
|
|
||||||
let javaPath;
|
console.log('Поиск Java...');
|
||||||
|
|
||||||
|
let javaPath = 'java';
|
||||||
try {
|
try {
|
||||||
javaPath = await findJava();
|
javaPath = await findJava();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Ошибка при поиске Java:', error);
|
console.warn('Ошибка при поиске Java:', error);
|
||||||
event.sender.send('installation-status', {
|
event.sender.send('installation-status', {
|
||||||
step: 'java-error',
|
step: 'java-error',
|
||||||
message: 'Не удалось найти Java. Используем системную Java.',
|
message:
|
||||||
|
'Не удалось найти Java. Попробуем запустить с системной Java.',
|
||||||
});
|
});
|
||||||
javaPath = 'java';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Далее пробуем установить Minecraft, но продолжаем даже при ошибках
|
console.log('Используем Java:', javaPath);
|
||||||
let resolvedVersion;
|
|
||||||
|
// --- 1. Установка ванильного Minecraft (effectiveBaseVersion) ---
|
||||||
|
let resolvedVersion: any;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Получаем список версий и устанавливаем ванильный Minecraft
|
|
||||||
event.sender.send('installation-status', {
|
event.sender.send('installation-status', {
|
||||||
step: 'minecraft-list',
|
step: 'minecraft-list',
|
||||||
message: 'Получение списка версий Minecraft...',
|
message: 'Получение списка версий Minecraft...',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('Получение списка версий Minecraft...');
|
||||||
|
|
||||||
const versionList = await getVersionList();
|
const versionList = await getVersionList();
|
||||||
const minecraftVersion = versionList.versions.find(
|
const minecraftVersion = versionList.versions.find(
|
||||||
(v) => v.id === baseVersion,
|
(v) => v.id === effectiveBaseVersion,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('minecraftVersion:', minecraftVersion);
|
||||||
|
|
||||||
if (minecraftVersion) {
|
if (minecraftVersion) {
|
||||||
// Устанавливаем базовую версию Minecraft
|
const installMcTask = installTask(minecraftVersion, minecraftDir, {
|
||||||
event.sender.send('installation-status', {
|
skipRevalidate: true,
|
||||||
step: 'minecraft-install',
|
assetsDownloadConcurrency: 2,
|
||||||
message: `Установка Minecraft ${baseVersion}...`,
|
librariesDownloadConcurrency: 2,
|
||||||
|
dispatcher: agent,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
console.log('installMcTask started for', minecraftVersion.id);
|
||||||
const installMcTask = installTask(minecraftVersion, minecraftDir, {
|
|
||||||
skipRevalidate: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await installMcTask.startAndWait({
|
event.sender.send('installation-status', {
|
||||||
onStart(task) {
|
step: 'minecraft-install',
|
||||||
event.sender.send('installation-status', {
|
message: `Установка Minecraft ${minecraftVersion.id}...`,
|
||||||
step: `minecraft-install.${task.path}`,
|
});
|
||||||
message: `Начало: ${task.name || task.path}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onUpdate(task) {
|
|
||||||
const percentage =
|
|
||||||
Math.round(
|
|
||||||
(installMcTask.progress / installMcTask.total) * 100,
|
|
||||||
) || 0;
|
|
||||||
|
|
||||||
event.sender.send('download-progress', percentage);
|
await installMcTask.startAndWait({
|
||||||
event.sender.send('installation-status', {
|
onUpdate(task, chunkSize) {
|
||||||
step: `minecraft-install.${task.path}`,
|
// локальный прогресс инсталлятора XMCL
|
||||||
message: `Прогресс ${task.name || task.path}: ${percentage}% (${installMcTask.progress}/${installMcTask.total})`,
|
const local =
|
||||||
});
|
installMcTask.total > 0
|
||||||
},
|
? installMcTask.progress / installMcTask.total
|
||||||
onFailed(task, error) {
|
: 0;
|
||||||
console.warn(
|
|
||||||
`Ошибка при установке ${task.path}, продолжаем:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
event.sender.send('installation-status', {
|
|
||||||
step: `minecraft-install.${task.path}`,
|
|
||||||
message: `Ошибка: ${error.message}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSucceed(task) {
|
|
||||||
event.sender.send('installation-status', {
|
|
||||||
step: `minecraft-install.${task.path}`,
|
|
||||||
message: `Завершено: ${task.name || task.path}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Ошибка при установке Minecraft, продолжаем:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Устанавливаем Fabric
|
const global = getGlobalProgress('minecraft-install', local);
|
||||||
try {
|
event.sender.send('overall-progress', global);
|
||||||
event.sender.send('installation-status', {
|
},
|
||||||
step: 'fabric-list',
|
onFailed(task, error) {
|
||||||
message: 'Получение списка версий Fabric...',
|
const stepName = (task as any).path || task.name || 'unknown';
|
||||||
});
|
console.warn(
|
||||||
|
`[minecraft-install] step "${stepName}" failed: ${
|
||||||
|
(error as any).code ?? ''
|
||||||
|
} ${(error as any).message}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (fabricVersion) {
|
|
||||||
event.sender.send('installation-status', {
|
event.sender.send('installation-status', {
|
||||||
step: 'fabric-install',
|
step: `minecraft-install.${stepName}`,
|
||||||
message: `Установка Fabric ${fabricVersion}...`,
|
message: `Ошибка: ${(error as any).message}`,
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`Версия ${effectiveBaseVersion} не найдена в списке версий Minecraft. Предполагаем, что она уже установлена.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const agg = error as any;
|
||||||
|
const innerCount = Array.isArray(agg?.errors) ? agg.errors.length : 0;
|
||||||
|
|
||||||
await installFabric({
|
console.log(
|
||||||
minecraftVersion: baseVersion,
|
'Ошибка при установке ванильного Minecraft, продолжаем:',
|
||||||
version: fabricVersion, // Используйте напрямую, без .version
|
agg?.message || String(agg),
|
||||||
minecraft: minecraftDir,
|
innerCount ? `(внутренних ошибок: ${innerCount})` : '',
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.warn('Ошибка при установке Fabric, продолжаем:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Подготовка версии и установка зависимостей
|
// --- 2. Установка Fabric (только для модпаков) ---
|
||||||
try {
|
if (!isVanillaVersion && fabricVersion) {
|
||||||
// Используем идентификатор Fabric-версии
|
try {
|
||||||
const fabricVersionId = `${baseVersion}-fabric${fabricVersion}`;
|
event.sender.send('installation-status', {
|
||||||
|
step: 'fabric-install',
|
||||||
|
message: `Установка Fabric ${fabricVersion}...`,
|
||||||
|
});
|
||||||
|
|
||||||
event.sender.send('installation-status', {
|
event.sender.send(
|
||||||
step: 'version-parse',
|
'overall-progress',
|
||||||
message: 'Подготовка версии...',
|
getGlobalProgress('fabric-install', 0),
|
||||||
});
|
);
|
||||||
|
|
||||||
resolvedVersion = await Version.parse(
|
console.log('installFabric:', {
|
||||||
minecraftDir,
|
minecraftVersion: effectiveBaseVersion,
|
||||||
fabricVersionId,
|
fabricVersion,
|
||||||
|
minecraftDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
await installFabric({
|
||||||
|
minecraftVersion: effectiveBaseVersion,
|
||||||
|
version: fabricVersion,
|
||||||
|
minecraft: minecraftDir,
|
||||||
|
});
|
||||||
|
event.sender.send(
|
||||||
|
'overall-progress',
|
||||||
|
getGlobalProgress('fabric-install', 1),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Ошибка при установке Fabric, продолжаем:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Установка зависимостей для versionToLaunch ---
|
||||||
|
try {
|
||||||
|
if (!versionToLaunch) {
|
||||||
|
throw new Error('versionToLaunch не определён');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('version-parse:', {
|
||||||
|
minecraftDir,
|
||||||
|
versionToLaunch,
|
||||||
|
});
|
||||||
|
|
||||||
|
event.sender.send('installation-status', {
|
||||||
|
step: 'version-parse',
|
||||||
|
message: 'Подготовка версии...',
|
||||||
|
});
|
||||||
|
|
||||||
|
resolvedVersion = await Version.parse(minecraftDir, versionToLaunch);
|
||||||
|
|
||||||
|
event.sender.send('installation-status', {
|
||||||
|
step: 'dependencies',
|
||||||
|
message: 'Установка библиотек и ресурсов...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const depsTask = installDependenciesTask(resolvedVersion, {
|
||||||
|
skipRevalidate: true,
|
||||||
|
prevalidSizeOnly: true,
|
||||||
|
dispatcher: agent,
|
||||||
|
assetsDownloadConcurrency: 2,
|
||||||
|
librariesDownloadConcurrency: 2,
|
||||||
|
checksumValidatorResolver: () => ({
|
||||||
|
async validate() {
|
||||||
|
// Игнорируем sha1, чтобы не падать из-за несоответствий
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// ...DOWNLOAD_OPTIONS,
|
||||||
|
});
|
||||||
|
|
||||||
|
await depsTask.startAndWait({
|
||||||
|
onUpdate(task, chunkSize) {
|
||||||
|
const local =
|
||||||
|
depsTask.total > 0 ? depsTask.progress / depsTask.total : 0;
|
||||||
|
|
||||||
|
const global = getGlobalProgress('dependencies', local);
|
||||||
|
event.sender.send('overall-progress', global);
|
||||||
|
},
|
||||||
|
onFailed(task, error) {
|
||||||
|
const stepName = (task as any).path || task.name || 'unknown';
|
||||||
|
console.warn(
|
||||||
|
`[deps] step "${stepName}" failed: ${
|
||||||
|
(error as any).code ?? ''
|
||||||
|
} ${(error as any).message}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
event.sender.send('installation-status', {
|
event.sender.send('installation-status', {
|
||||||
step: 'dependencies',
|
step: `dependencies.${stepName}`,
|
||||||
message: 'Установка библиотек и ресурсов...',
|
message: `Ошибка: ${(error as any).message}`,
|
||||||
});
|
});
|
||||||
|
},
|
||||||
const depsTask = installDependenciesTask(resolvedVersion, {
|
});
|
||||||
assetsDownloadConcurrency: 4,
|
} catch (error: any) {
|
||||||
skipRevalidate: true,
|
console.log(
|
||||||
prevalidSizeOnly: true,
|
'Ошибка при подготовке версии/зависимостей, продолжаем запуск:',
|
||||||
checksumValidatorResolver: (checksum) => ({
|
error.message || error,
|
||||||
validate: async () => {
|
);
|
||||||
/* void */
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await depsTask.startAndWait({
|
|
||||||
onStart(task) {
|
|
||||||
event.sender.send('installation-status', {
|
|
||||||
step: `dependencies.${task.path}`,
|
|
||||||
message: `Начало: ${task.name || task.path}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onUpdate(task) {
|
|
||||||
const percentage =
|
|
||||||
Math.round((depsTask.progress / depsTask.total) * 100) || 0;
|
|
||||||
|
|
||||||
event.sender.send('download-progress', percentage);
|
|
||||||
event.sender.send('installation-status', {
|
|
||||||
step: `dependencies.${task.path}`,
|
|
||||||
message: `Установка ${task.name || task.path}: ${percentage}%`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onFailed(task, error) {
|
|
||||||
console.warn(
|
|
||||||
`Ошибка при установке ${task.path}, продолжаем:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
event.sender.send('installation-status', {
|
|
||||||
step: `dependencies.${task.path}`,
|
|
||||||
message: `Ошибка: ${error.message}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSucceed(task) {
|
|
||||||
event.sender.send('installation-status', {
|
|
||||||
step: `dependencies.${task.path}`,
|
|
||||||
message: `Завершено: ${task.name || task.path}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
'Ошибка при загрузке ресурсов, продолжаем запуск:',
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Ошибка при подготовке версии, продолжаем:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Произошла ошибка при подготовке Minecraft:', error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка и проверка authlib-injector
|
// --- authlib-injector ---
|
||||||
const authlibPath = await ensureAuthlibInjectorExists(appPath);
|
const authlibPath = await ensureAuthlibInjectorExists(appPath);
|
||||||
|
console.log('authlibPath:', authlibPath);
|
||||||
|
|
||||||
event.sender.send('installation-status', {
|
event.sender.send('installation-status', {
|
||||||
step: 'authlib-injector',
|
step: 'authlib-injector',
|
||||||
message: 'authlib-injector готов',
|
message: 'authlib-injector готов',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Запускаем Minecraft с authlib-injector для Ely.by
|
// --- Запуск игры ---
|
||||||
|
console.log('Запуск игры...');
|
||||||
|
|
||||||
event.sender.send('installation-status', {
|
event.sender.send('installation-status', {
|
||||||
step: 'launch',
|
step: 'launch',
|
||||||
message: 'Запуск игры...',
|
message: 'Запуск игры...',
|
||||||
});
|
});
|
||||||
|
|
||||||
// При запуске используем переданные параметры
|
event.sender.send('overall-progress', getGlobalProgress('launch', 0));
|
||||||
const packDir = path.join(versionsDir, packName);
|
|
||||||
|
|
||||||
// При формировании конфигурации запуска создаем объект server только с нужными параметрами
|
|
||||||
const serverConfig: any = { ip: serverIp };
|
|
||||||
|
|
||||||
// Добавляем порт только если он был передан
|
|
||||||
if (serverPort) {
|
|
||||||
serverConfig.port = serverPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = await launch({
|
const proc = await launch({
|
||||||
gamePath: packDir,
|
gamePath: packDir,
|
||||||
resourcePath: minecraftDir,
|
resourcePath: minecraftDir,
|
||||||
javaPath,
|
javaPath,
|
||||||
version: versionToLaunch,
|
version: versionToLaunch!,
|
||||||
launcherName: 'popa-popa',
|
launcherName: 'popa-popa',
|
||||||
server: serverConfig, // Используем созданный объект конфигурации
|
|
||||||
extraJVMArgs: [
|
extraJVMArgs: [
|
||||||
'-Dlog4j2.formatMsgNoLookups=true',
|
'-Dlog4j2.formatMsgNoLookups=true',
|
||||||
`-javaagent:${authlibPath}=ely.by`,
|
`-javaagent:${authlibPath}=${API_BASE_URL}`,
|
||||||
`-Xmx${memory}M`,
|
`-Xmx${memory}M`,
|
||||||
|
'-Dauthlibinjector.skinWhitelist=https://minecraft.api.popa-popa.ru/',
|
||||||
|
'-Dauthlibinjector.debug=verbose,authlib',
|
||||||
|
'-Dauthlibinjector.legacySkinPolyfill=enabled',
|
||||||
|
'-Dauthlibinjector.mojangAntiFeatures=disabled',
|
||||||
|
'-Dcom.mojang.authlib.disableSecureProfileEndpoints=true',
|
||||||
|
],
|
||||||
|
extraMCArgs: [
|
||||||
|
'--quickPlayMultiplayer',
|
||||||
|
`${serverIp}:${serverPort || 25565}`,
|
||||||
],
|
],
|
||||||
// Используем данные аутентификации Yggdrasil
|
|
||||||
accessToken,
|
accessToken,
|
||||||
gameProfile: {
|
gameProfile: {
|
||||||
id: uuid,
|
id: uuid,
|
||||||
@ -744,23 +847,83 @@ export function initMinecraftHandlers() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Логирование
|
event.sender.send('minecraft-started', { pid: proc.pid });
|
||||||
|
|
||||||
|
currentMinecraftProcess = proc;
|
||||||
|
|
||||||
|
event.sender.send('overall-progress', getGlobalProgress('launch', 1));
|
||||||
|
|
||||||
|
let stderrBuffer = '';
|
||||||
|
|
||||||
proc.stdout?.on('data', (data) => {
|
proc.stdout?.on('data', (data) => {
|
||||||
console.log(`Minecraft stdout: ${data}`);
|
console.log(`Minecraft stdout: ${data}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.stderr?.on('data', (data) => {
|
proc.stderr?.on('data', (data) => {
|
||||||
console.error(`Minecraft stderr: ${data}`);
|
const text = data.toString();
|
||||||
|
console.error(`Minecraft stderr: ${text}`);
|
||||||
|
stderrBuffer += text;
|
||||||
|
|
||||||
|
// Пробрасываем сырой лог клиенту (если захочешь где-то выводить)
|
||||||
|
event.sender.send('minecraft-log', text);
|
||||||
|
|
||||||
|
// Если это ошибка — сразу уведомим пользователя
|
||||||
|
if (text.toLowerCase().includes('error')) {
|
||||||
|
event.sender.send('minecraft-error', {
|
||||||
|
message: text,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
proc.on('exit', (code) => {
|
||||||
|
console.log('Minecraft exited with code', code);
|
||||||
|
|
||||||
|
currentMinecraftProcess = null;
|
||||||
|
event.sender.send('minecraft-stopped', { code });
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
event.sender.send('installation-status', {
|
||||||
|
step: 'error',
|
||||||
|
message: `Minecraft завершился с ошибкой (код ${code})`,
|
||||||
|
});
|
||||||
|
|
||||||
|
event.sender.send('minecraft-error', {
|
||||||
|
message: `Minecraft завершился с ошибкой (код ${code})`,
|
||||||
|
stderr: stderrBuffer,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Запуск игры...');
|
||||||
|
|
||||||
return { success: true, pid: proc.pid };
|
return { success: true, pid: proc.pid };
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Ошибка при запуске Minecraft:', error);
|
console.error('Ошибка при запуске Minecraft:', error);
|
||||||
event.sender.send('installation-status', {
|
event.sender.send('installation-status', {
|
||||||
step: 'error',
|
step: 'error',
|
||||||
message: `Ошибка запуска: ${error.message}`,
|
message: `Ошибка запуска: ${error.message || String(error)}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message || String(error) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('stop-minecraft', async (event) => {
|
||||||
|
try {
|
||||||
|
if (currentMinecraftProcess && !currentMinecraftProcess.killed) {
|
||||||
|
console.log('Останавливаем Minecraft по запросу пользователя...');
|
||||||
|
// На Windows этого обычно достаточно
|
||||||
|
currentMinecraftProcess.kill();
|
||||||
|
|
||||||
|
// Можно чуть подождать, но не обязательно
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: 'Minecraft сейчас не запущен' };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Ошибка при остановке Minecraft:', error);
|
||||||
|
return { success: false, error: error.message || String(error) };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -804,6 +967,110 @@ export function initMinecraftHandlers() {
|
|||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ПРОБЛЕМА: У вас два обработчика для одного и того же канала 'get-installed-versions'
|
||||||
|
// РЕШЕНИЕ: Объединим логику в один обработчик, а из второго обработчика вызовем функцию getInstalledVersions
|
||||||
|
|
||||||
|
// Сначала создаем общую функцию для получения установленных версий
|
||||||
|
function getInstalledVersions() {
|
||||||
|
try {
|
||||||
|
const appPath = path.dirname(app.getPath('exe'));
|
||||||
|
const minecraftDir = path.join(appPath, '.minecraft');
|
||||||
|
const versionsDir = path.join(minecraftDir, 'versions');
|
||||||
|
|
||||||
|
if (!fs.existsSync(versionsDir)) {
|
||||||
|
return { success: true, versions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = fs.readdirSync(versionsDir);
|
||||||
|
const versions = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const versionPath = path.join(versionsDir, item);
|
||||||
|
if (!fs.statSync(versionPath).isDirectory()) continue;
|
||||||
|
|
||||||
|
// ❗ Прячем технические fabric-версии типа 1.21.10-fabric0.18.1
|
||||||
|
if (item.includes('-fabric')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionJsonPath = path.join(versionPath, `${item}.json`);
|
||||||
|
let versionInfo = {
|
||||||
|
id: item,
|
||||||
|
name: item,
|
||||||
|
version: item,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fs.existsSync(versionJsonPath)) {
|
||||||
|
try {
|
||||||
|
const versionData = JSON.parse(
|
||||||
|
fs.readFileSync(versionJsonPath, 'utf8'),
|
||||||
|
);
|
||||||
|
versionInfo.version = versionData.id || item;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Ошибка при чтении файла версии ${item}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
versions.push(versionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, versions };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при получении установленных версий:', error);
|
||||||
|
return { success: false, error: error.message, versions: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регистрируем обработчик для get-installed-versions
|
||||||
|
ipcMain.handle('get-installed-versions', async () => {
|
||||||
|
return getInstalledVersions();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработчик get-available-versions использует функцию getInstalledVersions
|
||||||
|
ipcMain.handle('get-available-versions', async (event, { gistUrl }) => {
|
||||||
|
try {
|
||||||
|
// Используем URL из параметров или значение по умолчанию
|
||||||
|
const url =
|
||||||
|
gistUrl ||
|
||||||
|
'https://gist.githubusercontent.com/DIKER0K/06cd12fb3a4d08b1f0f8c763a7d05e06/raw/versions.json';
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch versions from Gist: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = await response.json();
|
||||||
|
|
||||||
|
// Получаем уже установленные версии
|
||||||
|
const installedResult = getInstalledVersions();
|
||||||
|
const installedVersions = installedResult.success
|
||||||
|
? installedResult.versions
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Добавляем флаг installed к каждой версии
|
||||||
|
const versionsWithInstallStatus = versions.map((version: any) => {
|
||||||
|
const isInstalled = installedVersions.some(
|
||||||
|
(installed: any) => installed.id === version.id,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...version,
|
||||||
|
installed: isInstalled,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
versions: versionsWithInstallStatus,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при получении доступных версий:', error);
|
||||||
|
return { success: false, error: error.message, versions: [] };
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем обработчики IPC для аутентификации
|
// Добавляем обработчики IPC для аутентификации
|
||||||
@ -824,22 +1091,18 @@ export function initAuthHandlers() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Валидация токена
|
// Валидация токена
|
||||||
ipcMain.handle('validate-token', async (event, accessToken) => {
|
ipcMain.handle(
|
||||||
try {
|
'validate-token',
|
||||||
const clientToken = JSON.parse(
|
async (event, { accessToken, clientToken }) => {
|
||||||
fs.readFileSync(
|
try {
|
||||||
path.join(app.getPath('userData'), 'config.json'),
|
const valid = await authService.validate(accessToken, clientToken);
|
||||||
'utf8',
|
return { valid };
|
||||||
),
|
} catch (error) {
|
||||||
).clientToken;
|
console.error('Ошибка валидации токена:', error);
|
||||||
|
return { valid: false };
|
||||||
const valid = await authService.validate(accessToken, clientToken);
|
}
|
||||||
return { valid };
|
},
|
||||||
} catch (error) {
|
);
|
||||||
console.error('Ошибка валидации токена:', error);
|
|
||||||
return { valid: false };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Обновление токена
|
// Обновление токена
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
@ -979,3 +1242,59 @@ export function initPackConfigHandlers() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Добавляем после обработчика get-available-versions
|
||||||
|
ipcMain.handle('get-version-config', async (event, { versionId }) => {
|
||||||
|
try {
|
||||||
|
const appPath = path.dirname(app.getPath('exe'));
|
||||||
|
const minecraftDir = path.join(appPath, '.minecraft');
|
||||||
|
const versionsDir = path.join(minecraftDir, 'versions');
|
||||||
|
const versionPath = path.join(versionsDir, versionId);
|
||||||
|
|
||||||
|
// Проверяем существование директории версии
|
||||||
|
if (!fs.existsSync(versionPath)) {
|
||||||
|
return { success: false, error: `Версия ${versionId} не найдена` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем конфигурационный файл версии
|
||||||
|
const configPath = path.join(versionPath, 'popa-launcher-config.json');
|
||||||
|
|
||||||
|
// Определяем базовые настройки по умолчанию
|
||||||
|
let config = {
|
||||||
|
downloadUrl: '',
|
||||||
|
apiReleaseUrl: '',
|
||||||
|
versionFileName: `${versionId}_version.txt`,
|
||||||
|
packName: versionId,
|
||||||
|
memory: 4096,
|
||||||
|
baseVersion: '1.21.10',
|
||||||
|
serverIp: 'popa-popa.ru',
|
||||||
|
fabricVersion: '0.18.1',
|
||||||
|
preserveFiles: ['popa-launcher-config.json'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Если это Comfort, используем настройки по умолчанию
|
||||||
|
if (versionId === 'Comfort') {
|
||||||
|
config.downloadUrl =
|
||||||
|
'https://github.com/DIKER0K/Comfort/releases/latest/download/Comfort.zip';
|
||||||
|
config.apiReleaseUrl =
|
||||||
|
'https://api.github.com/repos/DIKER0K/Comfort/releases/latest';
|
||||||
|
config.baseVersion = '1.21.10';
|
||||||
|
config.fabricVersion = '0.18.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если есть конфигурационный файл, загружаем из него
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
try {
|
||||||
|
const savedConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
config = { ...config, ...savedConfig };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Ошибка чтения конфигурации ${versionId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, config };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка получения настроек версии:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -11,7 +11,15 @@ export type Channels =
|
|||||||
| 'save-pack-config'
|
| 'save-pack-config'
|
||||||
| 'load-pack-config'
|
| 'load-pack-config'
|
||||||
| 'update-available'
|
| 'update-available'
|
||||||
| 'install-update';
|
| 'install-update'
|
||||||
|
| 'get-installed-versions'
|
||||||
|
| 'get-available-versions'
|
||||||
|
| 'minecraft-log'
|
||||||
|
| 'minecraft-error'
|
||||||
|
| 'overall-progress'
|
||||||
|
| 'stop-minecraft'
|
||||||
|
| 'minecraft-started'
|
||||||
|
| 'minecraft-stopped';
|
||||||
|
|
||||||
const electronHandler = {
|
const electronHandler = {
|
||||||
ipcRenderer: {
|
ipcRenderer: {
|
||||||
|
|||||||
@ -12,16 +12,64 @@
|
|||||||
url('../../assets/fonts/benzin-bold.svg#benzin-bold') format('svg'); /* Chrome < 4, Legacy iOS */
|
url('../../assets/fonts/benzin-bold.svg#benzin-bold') format('svg'); /* Chrome < 4, Legacy iOS */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SETTINGS NO-BLUR */
|
||||||
|
.glass {
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-ui {
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass--soft { backdrop-filter: blur(6px); }
|
||||||
|
.glass--hard { backdrop-filter: blur(20px); }
|
||||||
|
|
||||||
|
body.no-blur .glass,
|
||||||
|
body.no-blur .glass-ui {
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
/* SETTINGS NO-BLUR */
|
||||||
|
|
||||||
|
/* SETTINGS REDUCE-MOTION */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.reduce-motion *,
|
||||||
|
body.reduce-motion *::before,
|
||||||
|
body.reduce-motion *::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* опционально: убрать ховер-скейлы (если ты их часто используешь) */
|
||||||
|
body.reduce-motion .no-motion-hover,
|
||||||
|
body.reduce-motion .no-motion-hover:hover {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
/* SETTINGS REDUCE-MOTION */
|
||||||
|
|
||||||
body {
|
body {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: white;
|
color: white;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: linear-gradient(242.94deg, #000000 39.07%, #3b4187 184.73%);
|
background: linear-gradient(242.94deg, #000000 39.07%, #3b4187 184.73%);
|
||||||
font-family: 'Benzin-Bold' !important;
|
font-family: 'Benzin-Bold' !important;
|
||||||
overflow-y: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@ -47,3 +95,48 @@ h4 {
|
|||||||
h5 {
|
h5 {
|
||||||
font-family: 'Benzin-Bold' !important;
|
font-family: 'Benzin-Bold' !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
font-family: 'Benzin-Bold' !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-family: 'Benzin-Bold' !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hover эффект */
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation-duration: 1.7s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* shimmer-анимация градиента */
|
||||||
|
@keyframes scrollbarShimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
Routes,
|
Routes,
|
||||||
Route,
|
Route,
|
||||||
Navigate,
|
Navigate,
|
||||||
|
useNavigate,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import LaunchPage from './pages/LaunchPage';
|
import LaunchPage from './pages/LaunchPage';
|
||||||
@ -10,21 +11,20 @@ import { ReactNode, useEffect, useState } from 'react';
|
|||||||
import './App.css';
|
import './App.css';
|
||||||
import TopBar from './components/TopBar';
|
import TopBar from './components/TopBar';
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import MinecraftBackround from './components/MinecraftBackround';
|
import MinecraftBackground from './components/MinecraftBackground';
|
||||||
import { Notifier } from './components/Notifier';
|
import { Notifier } from './components/Notifier';
|
||||||
|
import { VersionsExplorer } from './pages/VersionsExplorer';
|
||||||
// Переместите launchOptions сюда, вне компонентов
|
import Profile from './pages/Profile';
|
||||||
const launchOptions = {
|
import Shop from './pages/Shop';
|
||||||
downloadUrl:
|
import Marketplace from './pages/Marketplace';
|
||||||
'https://github.com/DIKER0K/Comfort/releases/latest/download/Comfort.zip',
|
import { Registration } from './pages/Registration';
|
||||||
apiReleaseUrl: 'https://api.github.com/repos/DIKER0K/Comfort/releases/latest',
|
import { FullScreenLoader } from './components/FullScreenLoader';
|
||||||
versionFileName: 'comfort_version.txt',
|
import { News } from './pages/News';
|
||||||
packName: 'Comfort',
|
import PageHeader from './components/PageHeader';
|
||||||
memory: 4096,
|
import { useLocation } from 'react-router-dom';
|
||||||
baseVersion: '1.21.4',
|
import DailyReward from './pages/DailyReward';
|
||||||
serverIp: 'popa-popa.ru',
|
import DailyQuests from './pages/DailyQuests';
|
||||||
fabricVersion: '0.16.14', // Уберите префикс "fabric"
|
import Settings from './pages/Settings';
|
||||||
};
|
|
||||||
|
|
||||||
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||||
@ -37,7 +37,10 @@ const AuthCheck = ({ children }: { children: ReactNode }) => {
|
|||||||
const config = JSON.parse(savedConfig);
|
const config = JSON.parse(savedConfig);
|
||||||
if (config.accessToken) {
|
if (config.accessToken) {
|
||||||
// Можно добавить дополнительную проверку токена
|
// Можно добавить дополнительную проверку токена
|
||||||
const isValid = await validateToken(config.accessToken);
|
const isValid = await validateToken(
|
||||||
|
config.accessToken,
|
||||||
|
config.clientToken,
|
||||||
|
);
|
||||||
setIsAuthenticated(isValid);
|
setIsAuthenticated(isValid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -52,64 +55,243 @@ const AuthCheck = ({ children }: { children: ReactNode }) => {
|
|||||||
checkAuth();
|
checkAuth();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const validateToken = async (token: string) => {
|
const validateToken = async (accessToken: string, clientToken: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://authserver.ely.by/auth/validate', {
|
// Используем IPC для валидации токена через main процесс
|
||||||
method: 'POST',
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
headers: {
|
'validate-token',
|
||||||
'Content-Type': 'application/json',
|
{ accessToken, clientToken },
|
||||||
},
|
);
|
||||||
body: JSON.stringify({ accessToken: token }),
|
|
||||||
});
|
// Если токен недействителен, очищаем сохраненные данные в localStorage
|
||||||
return response.ok;
|
if (!result.valid) {
|
||||||
|
console.log(
|
||||||
|
'Токен недействителен, очищаем данные авторизации из localStorage',
|
||||||
|
);
|
||||||
|
const savedConfig = localStorage.getItem('launcher_config');
|
||||||
|
if (savedConfig) {
|
||||||
|
const config = JSON.parse(savedConfig);
|
||||||
|
// Сохраняем только логин и другие настройки, но удаляем токены
|
||||||
|
const cleanedConfig = {
|
||||||
|
username: config.username,
|
||||||
|
memory: config.memory || 4096,
|
||||||
|
comfortVersion: config.comfortVersion || '',
|
||||||
|
password: '', // Очищаем пароль для безопасности
|
||||||
|
};
|
||||||
|
localStorage.setItem(
|
||||||
|
'launcher_config',
|
||||||
|
JSON.stringify(cleanedConfig),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.valid;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки токена:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isAuthenticated === null) {
|
if (isAuthenticated === null) {
|
||||||
return <div>Loading...</div>;
|
return <FullScreenLoader message="Загрузка..." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
// Просто используйте window.open без useNavigate
|
|
||||||
const handleRegister = () => {
|
|
||||||
window.open('https://account.ely.by/register', '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<Box
|
<AppLayout />
|
||||||
sx={{
|
|
||||||
height: '100vh',
|
|
||||||
width: '100vw',
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MinecraftBackround />
|
|
||||||
<TopBar onRegister={handleRegister} />
|
|
||||||
<Notifier />
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route
|
|
||||||
path="/"
|
|
||||||
element={
|
|
||||||
<AuthCheck>
|
|
||||||
<LaunchPage launchOptions={launchOptions} />
|
|
||||||
</AuthCheck>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</Box>
|
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AppLayout = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
const applySettings = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('launcher_settings');
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
const settings = JSON.parse(raw);
|
||||||
|
|
||||||
|
document.body.classList.toggle(
|
||||||
|
'reduce-motion',
|
||||||
|
Boolean(settings.reduceMotion),
|
||||||
|
);
|
||||||
|
|
||||||
|
document.body.classList.toggle(
|
||||||
|
'no-blur',
|
||||||
|
settings.blurEffects === false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ui = document.getElementById('app-ui');
|
||||||
|
if (ui && typeof settings.uiScale === 'number') {
|
||||||
|
const scale = settings.uiScale / 100;
|
||||||
|
|
||||||
|
ui.style.transform = `scale(${scale})`;
|
||||||
|
ui.style.transformOrigin = 'top left';
|
||||||
|
ui.style.width = `${100 / scale}%`;
|
||||||
|
ui.style.height = `${100 / scale}%`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to apply UI settings', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// применяем при загрузке
|
||||||
|
applySettings();
|
||||||
|
|
||||||
|
// применяем после сохранения настроек
|
||||||
|
window.addEventListener('settings-updated', applySettings);
|
||||||
|
return () => window.removeEventListener('settings-updated', applySettings);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Просто используйте window.open без useNavigate
|
||||||
|
const handleRegister = () => {
|
||||||
|
window.open('https://account.ely.by/register', '_blank');
|
||||||
|
};
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
|
const location = useLocation();
|
||||||
|
const path = location.pathname;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedConfig = localStorage.getItem('launcher_config');
|
||||||
|
if (savedConfig) {
|
||||||
|
const config = JSON.parse(savedConfig);
|
||||||
|
if (config.username) {
|
||||||
|
setUsername(config.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ width: '100vw', height: '100vh', position: 'relative', overflow: 'hidden' }}>
|
||||||
|
{/* ФОН — НЕ масштабируется */}
|
||||||
|
<Box sx={{ position: 'fixed', inset: 0, zIndex: 0, pointerEvents: 'none', }}>
|
||||||
|
<MinecraftBackground />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* UI — масштабируется */}
|
||||||
|
<Box
|
||||||
|
id="app-scroll"
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
id="app-ui"
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
height: '100vh',
|
||||||
|
width: '100vw',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent:
|
||||||
|
path === '/profile' ||
|
||||||
|
path.startsWith('/launch') ||
|
||||||
|
path === '/login' ||
|
||||||
|
path === '/registration'
|
||||||
|
? 'center'
|
||||||
|
: 'flex-start',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TopBar onRegister={handleRegister} username={username || ''} />
|
||||||
|
<PageHeader />
|
||||||
|
<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="/daily"
|
||||||
|
element={
|
||||||
|
<AuthCheck>
|
||||||
|
<DailyReward />
|
||||||
|
</AuthCheck>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dailyquests"
|
||||||
|
element={
|
||||||
|
<AuthCheck>
|
||||||
|
<DailyQuests />
|
||||||
|
</AuthCheck>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<AuthCheck>
|
||||||
|
<Settings />
|
||||||
|
</AuthCheck>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/shop"
|
||||||
|
element={
|
||||||
|
<AuthCheck>
|
||||||
|
<Shop />
|
||||||
|
</AuthCheck>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/marketplace"
|
||||||
|
element={
|
||||||
|
<AuthCheck>
|
||||||
|
<Marketplace />
|
||||||
|
</AuthCheck>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/news"
|
||||||
|
element={
|
||||||
|
<AuthCheck>
|
||||||
|
<News />
|
||||||
|
</AuthCheck>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
1180
src/renderer/api.ts
Normal file
1180
src/renderer/api.ts
Normal file
File diff suppressed because it is too large
Load Diff
13
assets/assets.d.ts → src/renderer/assets.d.ts
vendored
13
assets/assets.d.ts → src/renderer/assets.d.ts
vendored
@ -33,3 +33,16 @@ declare module '*.css' {
|
|||||||
const content: Styles;
|
const content: Styles;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '*.mp3' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
declare module '*.wav' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
declare module '*.ogg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
51
src/renderer/assets/Icons/GradientVisibilityToggleIcon.tsx
Normal file
51
src/renderer/assets/Icons/GradientVisibilityToggleIcon.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
|
||||||
|
|
||||||
|
type Props = SvgIconProps & {
|
||||||
|
crossed?: boolean; // true = перечеркнуть
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GradientVisibilityToggleIcon({ crossed, sx, ...props }: Props) {
|
||||||
|
const id = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SvgIcon
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
sx={{
|
||||||
|
...sx,
|
||||||
|
|
||||||
|
// анимация "рисования" линии
|
||||||
|
"& .slash": {
|
||||||
|
strokeDasharray: 100,
|
||||||
|
strokeDashoffset: crossed ? 0 : 100,
|
||||||
|
transition: "stroke-dashoffset 520ms ease",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={id} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#F27121" />
|
||||||
|
<stop offset="70%" stopColor="#E940CD" />
|
||||||
|
<stop offset="100%" stopColor="#8A2387" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* сам "глаз" */}
|
||||||
|
<path
|
||||||
|
fill={`url(#${id})`}
|
||||||
|
d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5M12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5m0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* линия "перечёркивания" */}
|
||||||
|
<path
|
||||||
|
className="slash"
|
||||||
|
d="M4 4 L20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${id})`}
|
||||||
|
strokeWidth="2.4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</SvgIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
src/renderer/assets/sounds/buy.mp3
Normal file
BIN
src/renderer/assets/sounds/buy.mp3
Normal file
Binary file not shown.
BIN
src/renderer/assets/sounds/sell.mp3
Normal file
BIN
src/renderer/assets/sounds/sell.mp3
Normal file
Binary file not shown.
309
src/renderer/components/BonusShopItem.tsx
Normal file
309
src/renderer/components/BonusShopItem.tsx
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
// src/renderer/components/BonusShopItem.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
CardMedia,
|
||||||
|
Divider,
|
||||||
|
} 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: 220,
|
||||||
|
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.01)',
|
||||||
|
boxShadow: '0 10px 20px rgba(242,113,33,0.1)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Градиентный свет сверху — как в 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: '1rem',
|
||||||
|
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.7rem',
|
||||||
|
mb: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Уровень: {level}
|
||||||
|
{isPermanent && ' • Постоянный'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: 'rgba(255,255,255,0.75)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
mb: 1.2,
|
||||||
|
minHeight: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<Divider sx={{background: 'rgba(160, 160, 160, 0.3)', borderRadius: '2vw'}}/>
|
||||||
|
|
||||||
|
{!isBuyMode && onToggleActive && (
|
||||||
|
<Typography
|
||||||
|
onClick={onToggleActive}
|
||||||
|
sx={{
|
||||||
|
mt: '1vw',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '1vw',
|
||||||
|
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: 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isActive ? 'Выключить' : 'Включить'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</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.05)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BonusShopItem;
|
||||||
208
src/renderer/components/CapeCard.tsx
Normal file
208
src/renderer/components/CapeCard.tsx
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Box, Typography, Paper, Chip, Button } from '@mui/material';
|
||||||
|
import CustomTooltip from './Notifications/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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRADIENT =
|
||||||
|
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||||
|
|
||||||
|
export default function CapeCard({
|
||||||
|
cape,
|
||||||
|
mode,
|
||||||
|
onAction,
|
||||||
|
actionDisabled = false,
|
||||||
|
}: CapeCardProps) {
|
||||||
|
const capeId = cape.cape_id || cape.id || '';
|
||||||
|
const capeName = cape.cape_name || cape.name || 'Без названия';
|
||||||
|
const capeDescription = cape.cape_description || cape.description || '';
|
||||||
|
|
||||||
|
const action = useMemo(() => {
|
||||||
|
if (mode === 'shop') {
|
||||||
|
return { text: 'Купить', variant: 'gradient' as const };
|
||||||
|
}
|
||||||
|
return cape.is_active
|
||||||
|
? { text: 'Снять', variant: 'danger' as const }
|
||||||
|
: { text: 'Надеть', variant: 'success' as const };
|
||||||
|
}, [mode, cape.is_active]);
|
||||||
|
|
||||||
|
const topRightChip =
|
||||||
|
mode === 'shop' && cape.price !== undefined
|
||||||
|
? `${cape.price} коинов`
|
||||||
|
: cape.is_active
|
||||||
|
? 'Активен'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomTooltip arrow title={capeDescription} placement="bottom">
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
width: '16.5vw',
|
||||||
|
borderRadius: '1.2vw',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
color: 'white',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
transition:
|
||||||
|
'transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.01)',
|
||||||
|
borderColor: 'rgba(242,113,33,0.35)',
|
||||||
|
boxShadow: '0 1.4vw 3.8vw rgba(0,0,0,0.60)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* градиентная полоска-акцент (как у твоих блоков) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '0.35vw',
|
||||||
|
background: GRADIENT,
|
||||||
|
opacity: 0.9,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* chip справа сверху */}
|
||||||
|
{topRightChip && (
|
||||||
|
<Chip
|
||||||
|
label={topRightChip}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0.8vw',
|
||||||
|
right: '0.8vw',
|
||||||
|
zIndex: 3,
|
||||||
|
height: '1.55rem',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
fontWeight: 900,
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background:
|
||||||
|
mode === 'shop'
|
||||||
|
? 'rgba(0,0,0,0.65)'
|
||||||
|
: 'linear-gradient(120deg, rgba(242,113,33,0.22), rgba(233,64,205,0.14), rgba(138,35,135,0.18))',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* preview */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: '1.1vw',
|
||||||
|
pt: '1.0vw',
|
||||||
|
pb: '0.7vw',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: '1.0vw',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
maxHeight: '21vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Здесь показываем ЛЕВУЮ половину текстуры (лицевую часть) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '44vw',
|
||||||
|
height: '36vw',
|
||||||
|
minWidth: '462px',
|
||||||
|
minHeight: '380px',
|
||||||
|
imageRendering: 'pixelated',
|
||||||
|
backgroundImage: `url(${cape.image_url})`,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: '200% 100%', // важно: режем пополам “кадром”
|
||||||
|
backgroundPosition: 'left center',
|
||||||
|
ml: '-2vw',
|
||||||
|
// если нужно чуть увеличить/сдвинуть — делай через backgroundPosition
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* content */}
|
||||||
|
<Box sx={{ px: '1.1vw', pb: '1.1vw' }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '0.95vw',
|
||||||
|
minFontSize: 14,
|
||||||
|
color: 'rgba(255,255,255,0.92)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{capeName}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* действия */}
|
||||||
|
<Box sx={{ mt: '0.9vw', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
disableRipple
|
||||||
|
onClick={() => onAction(capeId)}
|
||||||
|
disabled={actionDisabled || !capeId}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '999px',
|
||||||
|
py: '0.75vw',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontWeight: 900,
|
||||||
|
color: '#fff',
|
||||||
|
background:
|
||||||
|
action.variant === 'gradient'
|
||||||
|
? GRADIENT
|
||||||
|
: action.variant === 'success'
|
||||||
|
? 'rgba(0, 134, 0, 0.95)'
|
||||||
|
: 'rgba(190, 35, 35, 0.95)',
|
||||||
|
boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.40)',
|
||||||
|
transition: 'transform 0.18s ease, filter 0.18s ease, opacity 0.18s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.01)',
|
||||||
|
filter: 'brightness(1.05)',
|
||||||
|
},
|
||||||
|
'&.Mui-disabled': {
|
||||||
|
background: 'rgba(255,255,255,0.10)',
|
||||||
|
color: 'rgba(255,255,255,0.55)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.text}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</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.02)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
src/renderer/components/CoinsDisplay.tsx
Normal file
239
src/renderer/components/CoinsDisplay.tsx
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
// CoinsDisplay.tsx
|
||||||
|
import { Box, Typography } from '@mui/material';
|
||||||
|
import CustomTooltip from './Notifications/CustomTooltip';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { fetchCoins } from '../api';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
interface CoinsDisplayProps {
|
||||||
|
value?: number;
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
showTooltip?: boolean;
|
||||||
|
tooltipText?: string;
|
||||||
|
showIcon?: boolean;
|
||||||
|
iconColor?: string;
|
||||||
|
|
||||||
|
autoUpdate?: boolean;
|
||||||
|
updateInterval?: number;
|
||||||
|
|
||||||
|
backgroundColor?: string;
|
||||||
|
textColor?: string;
|
||||||
|
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
|
||||||
|
sx,
|
||||||
|
}: CoinsDisplayProps) {
|
||||||
|
const storageKey = useMemo(() => {
|
||||||
|
// ключ под конкретного пользователя
|
||||||
|
return username ? `coins:${username}` : 'coins:anonymous';
|
||||||
|
}, [username]);
|
||||||
|
|
||||||
|
const readCachedCoins = (): number | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [coins, setCoins] = useState<number>(() => {
|
||||||
|
// 1) если пришло значение извне — оно приоритетнее
|
||||||
|
if (externalValue !== undefined) return externalValue;
|
||||||
|
|
||||||
|
// 2) иначе пробуем localStorage
|
||||||
|
const cached = readCachedCoins();
|
||||||
|
if (cached !== null) return cached;
|
||||||
|
|
||||||
|
// 3) иначе 0
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const getSizes = () => {
|
||||||
|
switch (size) {
|
||||||
|
case 'small':
|
||||||
|
return {
|
||||||
|
containerPadding: '0.4vw 0.8vw',
|
||||||
|
iconSize: '1.4vw',
|
||||||
|
fontSize: '1vw',
|
||||||
|
borderRadius: '2vw',
|
||||||
|
gap: '0.6vw',
|
||||||
|
};
|
||||||
|
case 'large':
|
||||||
|
return {
|
||||||
|
containerPadding: '0.4vw 1.2vw',
|
||||||
|
iconSize: '2.2vw',
|
||||||
|
fontSize: '1.6vw',
|
||||||
|
borderRadius: '1.8vw',
|
||||||
|
gap: '0.8vw',
|
||||||
|
};
|
||||||
|
case 'medium':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
containerPadding: '0.4vw 1vw',
|
||||||
|
iconSize: '2vw',
|
||||||
|
fontSize: '1.4vw',
|
||||||
|
borderRadius: '1.6vw',
|
||||||
|
gap: '0.6vw',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = getSizes();
|
||||||
|
|
||||||
|
const formatNumber = (num: number): string => {
|
||||||
|
return num.toLocaleString('ru-RU');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сохраняем актуальный баланс в localStorage при любом изменении coins
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, String(coins));
|
||||||
|
} catch {
|
||||||
|
// игнорируем (private mode, quota и т.п.)
|
||||||
|
}
|
||||||
|
}, [coins, storageKey]);
|
||||||
|
|
||||||
|
// Если пришло внешнее значение — обновляем и оно же попадёт в localStorage через эффект выше
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalValue !== undefined) {
|
||||||
|
setCoins(externalValue);
|
||||||
|
}
|
||||||
|
}, [externalValue]);
|
||||||
|
|
||||||
|
// При смене username можно сразу подхватить кэш, чтобы не мигало при первом fetch
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalValue !== undefined) return; // внешнее значение важнее
|
||||||
|
const cached = readCachedCoins();
|
||||||
|
if (cached !== null) setCoins(cached);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
const fetchCoinsData = async () => {
|
||||||
|
if (!username) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const coinsData = await fetchCoins(username);
|
||||||
|
// ВАЖНО: не показываем "..." — просто меняем число, когда пришёл ответ
|
||||||
|
setCoins(coinsData.coins);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при получении количества монет:', error);
|
||||||
|
// оставляем старое значение (из state/localStorage)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (username && autoUpdate) {
|
||||||
|
fetchCoinsData();
|
||||||
|
const coinsInterval = setInterval(fetchCoinsData, updateInterval);
|
||||||
|
return () => clearInterval(coinsInterval);
|
||||||
|
}
|
||||||
|
}, [username, autoUpdate, updateInterval]);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (username) fetchCoinsData();
|
||||||
|
};
|
||||||
|
|
||||||
|
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.85 : 1,
|
||||||
|
transition: 'opacity 0.2s ease',
|
||||||
|
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatNumber(coins)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (showTooltip) {
|
||||||
|
return (
|
||||||
|
<CustomTooltip
|
||||||
|
title={tooltipText}
|
||||||
|
arrow
|
||||||
|
placement="bottom"
|
||||||
|
TransitionProps={{ timeout: 300 }}
|
||||||
|
>
|
||||||
|
{coinsDisplay}
|
||||||
|
</CustomTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return coinsDisplay;
|
||||||
|
}
|
||||||
@ -8,12 +8,12 @@ import {
|
|||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Collapse,
|
Collapse,
|
||||||
CircularProgress,
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import FolderIcon from '@mui/icons-material/Folder';
|
import FolderIcon from '@mui/icons-material/Folder';
|
||||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||||
|
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||||
|
|
||||||
interface FileNode {
|
interface FileNode {
|
||||||
name: string;
|
name: string;
|
||||||
@ -190,7 +190,7 @@ export default function FilesSelector({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <CircularProgress />;
|
return <FullScreenLoader fullScreen={false} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
113
src/renderer/components/FullScreenLoader.tsx
Normal file
113
src/renderer/components/FullScreenLoader.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Fade from '@mui/material/Fade';
|
||||||
|
|
||||||
|
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}>
|
||||||
|
{/* Плавное появление фона */}
|
||||||
|
{fullScreen && (
|
||||||
|
<Fade in timeout={220} appear>
|
||||||
|
<Box
|
||||||
|
className="glass-ui"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 15% 20%, rgba(242,113,33,0.15), transparent 60%), radial-gradient(circle at 85% 10%, rgba(233,64,205,0.12), transparent 55%), rgba(5,5,10,0.75)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Fade>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Плавное появление контента */}
|
||||||
|
<Fade in timeout={260} appear>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
zIndex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 3,
|
||||||
|
// небольшой "подъём" при появлении
|
||||||
|
animation: document.body.classList.contains('reduce-motion')
|
||||||
|
? 'none'
|
||||||
|
: 'popIn 260ms ease-out both',
|
||||||
|
'@keyframes popIn': {
|
||||||
|
from: { opacity: 0, transform: 'translateY(8px) scale(0.98)' },
|
||||||
|
to: { opacity: 1, transform: 'translateY(0) scale(1)' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: '50%',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'conic-gradient(#F27121, #E940CD, #8A2387, #F27121)',
|
||||||
|
animation: document.body.classList.contains('reduce-motion')
|
||||||
|
? 'none'
|
||||||
|
: '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)' },
|
||||||
|
},
|
||||||
|
boxShadow: '0 0 2.5vw rgba(233,64,205,0.45)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
background:
|
||||||
|
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
textAlign: 'center',
|
||||||
|
textShadow: '0 0 1.2vw rgba(0,0,0,0.45)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
</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;
|
||||||
67
src/renderer/components/HeadAvatar.tsx
Normal file
67
src/renderer/components/HeadAvatar.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface HeadAvatarProps {
|
||||||
|
skinUrl?: string;
|
||||||
|
size?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
version?: number; // ✅ добавили
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SKIN =
|
||||||
|
'https://static.planetminecraft.com/files/resource_media/skin/original-steve-15053860.png';
|
||||||
|
|
||||||
|
export const HeadAvatar: React.FC<HeadAvatarProps> = ({
|
||||||
|
skinUrl,
|
||||||
|
size = 24,
|
||||||
|
style,
|
||||||
|
version = 0, // ✅ дефолт
|
||||||
|
...canvasProps
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const baseUrl = skinUrl?.trim() ? skinUrl : DEFAULT_SKIN;
|
||||||
|
|
||||||
|
// ✅ cache-bust: чтобы браузер НЕ отдавал старую картинку
|
||||||
|
const finalSkinUrl = `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}v=${version}`;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.src = finalSkinUrl;
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
ctx.drawImage(img, 8, 8, 8, 8, 0, 0, size, size);
|
||||||
|
ctx.drawImage(img, 40, 8, 8, 8, 0, 0, size, size);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = (e) => {
|
||||||
|
console.error('Не удалось загрузить скин для HeadAvatar:', e);
|
||||||
|
};
|
||||||
|
}, [skinUrl, size, version]); // ✅ version добавили
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
{...canvasProps}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: 4,
|
||||||
|
imageRendering: 'pixelated',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,4 +1,10 @@
|
|||||||
import { Box, Button, TextField, Typography } from '@mui/material';
|
import { useState } from "react";
|
||||||
|
import { Box, Button, TextField, Typography, InputAdornment, IconButton } from '@mui/material';
|
||||||
|
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
import GradientTextField from '../GradientTextField';
|
||||||
|
import GradientVisibilityToggleIcon from '../../assets/Icons/GradientVisibilityToggleIcon'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
interface AuthFormProps {
|
interface AuthFormProps {
|
||||||
config: {
|
config: {
|
||||||
@ -10,28 +16,111 @@ interface AuthFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
|
const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.5vw' }}>
|
<Box
|
||||||
<TextField
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.5vw',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GradientTextField
|
||||||
|
label="Никнейм"
|
||||||
required
|
required
|
||||||
name="username"
|
name="username"
|
||||||
label="Введите ник"
|
|
||||||
variant="outlined"
|
|
||||||
value={config.username}
|
value={config.username}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
sx={{
|
||||||
|
mt: '2.5vw',
|
||||||
|
mb: '0vw'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<GradientTextField
|
||||||
|
label="Пароль"
|
||||||
required
|
required
|
||||||
type="password"
|
type={showPassword ? "text" : "password"}
|
||||||
name="password"
|
name="password"
|
||||||
label="Введите пароль"
|
|
||||||
variant="outlined"
|
|
||||||
value={config.password}
|
value={config.password}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
sx={{
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
color: 'white',
|
||||||
|
padding: '1rem 0.7rem 1.1rem 1.5rem',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end" sx={{ margin: '0' }}>
|
||||||
|
<IconButton
|
||||||
|
disableRipple
|
||||||
|
disableFocusRipple
|
||||||
|
disableTouchRipple
|
||||||
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
|
edge="end"
|
||||||
|
sx={{
|
||||||
|
color: "white",
|
||||||
|
margin: '0',
|
||||||
|
padding: '0',
|
||||||
|
'& MuiTouchRipple-root css-r3djoj-MuiTouchRipple-root': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
<GradientVisibilityToggleIcon
|
||||||
|
crossed={showPassword} // когда type="text" -> перечеркнуть
|
||||||
|
sx={{ fontSize: "2.5vw", mr: '0.5vw' }}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={onLogin} variant="contained">
|
<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.02)',
|
||||||
|
|
||||||
|
},
|
||||||
|
}}>
|
||||||
Войти
|
Войти
|
||||||
</Button>
|
</Button>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
onClick={() => navigate('/registration')}
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '1vw',
|
||||||
|
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: 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Зарегистрироваться
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,25 +1,187 @@
|
|||||||
import { Slider } from '@mui/material';
|
import React from 'react';
|
||||||
|
import { Box, Slider, Typography } from '@mui/material';
|
||||||
|
|
||||||
interface MemorySliderProps {
|
interface MemorySliderProps {
|
||||||
memory: number;
|
memory: number;
|
||||||
onChange: (e: Event, value: number | number[]) => void;
|
onChange: (e: Event, value: number | number[]) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MemorySlider = ({ memory, onChange }: MemorySliderProps) => {
|
const gradientPrimary =
|
||||||
|
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||||
|
|
||||||
|
const formatMb = (v: number) => `${v} MB`;
|
||||||
|
const formatGb = (v: number) => `${(v / 1024).toFixed(v % 1024 === 0 ? 0 : 1)} GB`;
|
||||||
|
|
||||||
|
const MemorySlider = ({
|
||||||
|
memory,
|
||||||
|
onChange,
|
||||||
|
min = 1024,
|
||||||
|
max = 32768,
|
||||||
|
step = 1024,
|
||||||
|
}: MemorySliderProps) => {
|
||||||
|
// marks только на “красивых” значениях, чтобы не было каши
|
||||||
|
const marks = [
|
||||||
|
{ value: 1024, label: '1 GB' },
|
||||||
|
{ value: 4096, label: '4 GB' },
|
||||||
|
{ value: 8192, label: '8 GB' },
|
||||||
|
{ value: 16384, label: '16 GB' },
|
||||||
|
{ value: 32768, label: '32 GB' },
|
||||||
|
].filter((m) => m.value >= min && m.value <= max);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slider
|
<Box sx={{ width: '100%' }}>
|
||||||
name="memory"
|
{/* Header */}
|
||||||
aria-label="Memory"
|
<Box
|
||||||
defaultValue={4096}
|
sx={{
|
||||||
valueLabelDisplay="auto"
|
display: 'flex',
|
||||||
shiftStep={1024}
|
alignItems: 'baseline',
|
||||||
step={1024}
|
justifyContent: 'space-between',
|
||||||
marks
|
mb: '1.2vh',
|
||||||
min={1024}
|
}}
|
||||||
max={32628}
|
>
|
||||||
value={memory}
|
<Typography
|
||||||
onChange={onChange}
|
sx={{
|
||||||
/>
|
fontFamily: 'Benzin-Bold, system-ui, -apple-system, Segoe UI, Roboto, Arial',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: '1.1vw',
|
||||||
|
color: '#fff',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Память
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold, system-ui, -apple-system, Segoe UI, Roboto, Arial',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: '1.1vw',
|
||||||
|
backgroundImage: gradientPrimary,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{memory >= 1024 ? formatGb(memory) : formatMb(memory)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
name="memory"
|
||||||
|
aria-label="Memory"
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
valueLabelFormat={(v) => (v >= 1024 ? formatGb(v as number) : formatMb(v as number))}
|
||||||
|
shiftStep={step}
|
||||||
|
step={step}
|
||||||
|
marks={marks}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={memory}
|
||||||
|
onChange={onChange}
|
||||||
|
sx={{
|
||||||
|
px: '0.2vw',
|
||||||
|
|
||||||
|
// rail (фон полосы)
|
||||||
|
'& .MuiSlider-rail': {
|
||||||
|
opacity: 1,
|
||||||
|
height: '0.9vh',
|
||||||
|
borderRadius: '999vw',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.10)',
|
||||||
|
boxShadow: 'inset 0 0.25vh 0.6vh rgba(0,0,0,0.45)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// track (заполненная часть)
|
||||||
|
'& .MuiSlider-track': {
|
||||||
|
height: '0.9vh',
|
||||||
|
borderRadius: '999vw',
|
||||||
|
border: 'none',
|
||||||
|
background: gradientPrimary,
|
||||||
|
boxShadow: '0 0.6vh 1.6vh rgba(233,64,205,0.18)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// thumb (ползунок)
|
||||||
|
'& .MuiSlider-thumb': {
|
||||||
|
width: '1.6vw',
|
||||||
|
height: '1.6vw',
|
||||||
|
minWidth: 14,
|
||||||
|
minHeight: 14,
|
||||||
|
borderRadius: '999vw',
|
||||||
|
background: 'rgba(10,10,20,0.92)',
|
||||||
|
border: '0.22vw solid rgba(255,255,255,0.18)',
|
||||||
|
boxShadow:
|
||||||
|
'0 0.9vh 2.4vh rgba(0,0,0,0.55), 0 0 1.2vw rgba(242,113,33,0.20)',
|
||||||
|
transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease',
|
||||||
|
'&:hover': {
|
||||||
|
// transform: 'scale(1.06)',
|
||||||
|
borderColor: 'rgba(242,113,33,0.55)',
|
||||||
|
boxShadow:
|
||||||
|
'0 1.1vh 2.8vh rgba(0,0,0,0.62), 0 0 1.6vw rgba(233,64,205,0.28)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// внутренний “свет”
|
||||||
|
'&:before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '18%',
|
||||||
|
borderRadius: '999vw',
|
||||||
|
background: gradientPrimary,
|
||||||
|
opacity: 0.85,
|
||||||
|
filter: 'blur(0.3vw)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// value label (плашка значения)
|
||||||
|
'& .MuiSlider-valueLabel': {
|
||||||
|
fontFamily: 'Benzin-Bold, system-ui, -apple-system, Segoe UI, Roboto, Arial',
|
||||||
|
fontSize: '0.85vw',
|
||||||
|
borderRadius: '1.2vw',
|
||||||
|
padding: '0.4vh 0.8vw',
|
||||||
|
color: '#fff',
|
||||||
|
background: 'rgba(0,0,0,0.55)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
boxShadow: '0 1.2vh 3vh rgba(0,0,0,0.55)',
|
||||||
|
'&:before': { display: 'none' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// marks (точки)
|
||||||
|
'& .MuiSlider-mark': {
|
||||||
|
width: '0.35vw',
|
||||||
|
height: '0.35vw',
|
||||||
|
minWidth: 4,
|
||||||
|
minHeight: 4,
|
||||||
|
borderRadius: '999vw',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.18)',
|
||||||
|
},
|
||||||
|
'& .MuiSlider-markActive': {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.55)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// mark labels (подписи)
|
||||||
|
'& .MuiSlider-markLabel': {
|
||||||
|
color: 'rgba(255,255,255,0.55)',
|
||||||
|
fontSize: '0.75vw',
|
||||||
|
marginTop: '1vh',
|
||||||
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
|
||||||
|
// focus outline
|
||||||
|
'& .MuiSlider-thumb.Mui-focusVisible': {
|
||||||
|
outline: 'none',
|
||||||
|
boxShadow:
|
||||||
|
'0 0 0 0.25vw rgba(242,113,33,0.20), 0 1.1vh 2.8vh rgba(0,0,0,0.62)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Subtext */}
|
||||||
|
<Typography sx={{ mt: '1.2vh', color: 'rgba(255,255,255,0.55)', fontSize: '0.85vw' }}>
|
||||||
|
Шаг: {formatGb(step)} • Рекомендуем: 4–8 GB для большинства сборок
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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} />;
|
||||||
|
};
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import heart from '../../../assets/images/heart.svg';
|
import heart from '../../../assets/images/heart.svg';
|
||||||
|
|
||||||
export default function MinecraftBackround() {
|
export default function MinecraftBackground() {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
180
src/renderer/components/Notifications/CustomNotification.tsx
Normal file
180
src/renderer/components/Notifications/CustomNotification.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
|
||||||
|
export type NotificationVertical = 'top' | 'bottom';
|
||||||
|
export type NotificationHorizontal = 'left' | 'center' | 'right';
|
||||||
|
|
||||||
|
export type NotificationPosition = {
|
||||||
|
vertical: NotificationVertical;
|
||||||
|
horizontal: NotificationHorizontal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationSeverity = 'success' | 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface CustomNotificationProps {
|
||||||
|
open: boolean;
|
||||||
|
message: React.ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
|
||||||
|
severity?: NotificationSeverity;
|
||||||
|
position?: NotificationPosition;
|
||||||
|
|
||||||
|
autoHideDuration?: number;
|
||||||
|
variant?: 'filled' | 'outlined' | 'standard';
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAccent = (severity: NotificationSeverity) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'success':
|
||||||
|
return {
|
||||||
|
// glow: 'rgba(43, 255, 0, 0.45)',
|
||||||
|
// a1: 'rgba(43, 255, 0, 0.90)',
|
||||||
|
// a2: 'rgba(0, 255, 170, 0.55)',
|
||||||
|
// a3: 'rgba(0, 200, 120, 0.35)',
|
||||||
|
glow: 'rgba(138, 35, 135, 0.45)',
|
||||||
|
a1: 'rgba(242, 113, 33, 0.90)',
|
||||||
|
a2: 'rgba(138, 35, 135, 0.90)',
|
||||||
|
a3: 'rgba(233, 64, 205, 0.90)',
|
||||||
|
};
|
||||||
|
case 'warning':
|
||||||
|
return {
|
||||||
|
glow: 'rgba(255, 193, 7, 0.45)',
|
||||||
|
a1: 'rgba(255, 193, 7, 0.90)',
|
||||||
|
a2: 'rgba(255, 120, 0, 0.55)',
|
||||||
|
a3: 'rgba(255, 80, 0, 0.35)',
|
||||||
|
};
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
glow: 'rgba(255, 77, 77, 0.50)',
|
||||||
|
a1: 'rgba(255, 77, 77, 0.90)',
|
||||||
|
a2: 'rgba(233, 64, 87, 0.65)',
|
||||||
|
a3: 'rgba(138, 35, 135, 0.45)',
|
||||||
|
};
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
glow: 'rgba(33, 150, 243, 0.45)',
|
||||||
|
a1: 'rgba(33, 150, 243, 0.90)',
|
||||||
|
a2: 'rgba(0, 255, 255, 0.45)',
|
||||||
|
a3: 'rgba(120, 60, 255, 0.35)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CustomNotification({
|
||||||
|
open,
|
||||||
|
message,
|
||||||
|
onClose,
|
||||||
|
severity = 'info',
|
||||||
|
position = { vertical: 'bottom', horizontal: 'center' },
|
||||||
|
autoHideDuration = 3000,
|
||||||
|
variant = 'filled',
|
||||||
|
}: CustomNotificationProps) {
|
||||||
|
const accent = getAccent(severity);
|
||||||
|
|
||||||
|
const handleClose = (
|
||||||
|
_event?: React.SyntheticEvent | Event,
|
||||||
|
reason?: SnackbarCloseReason
|
||||||
|
) => {
|
||||||
|
if (reason === 'clickaway') return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Snackbar
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
autoHideDuration={autoHideDuration}
|
||||||
|
anchorOrigin={position}
|
||||||
|
sx={{
|
||||||
|
'& .MuiSnackbarContent-root': {
|
||||||
|
background: 'transparent',
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
onClose={handleClose}
|
||||||
|
severity={severity}
|
||||||
|
variant={variant}
|
||||||
|
icon={false}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: '1vw',
|
||||||
|
px: '2vw',
|
||||||
|
py: '1vw',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
|
||||||
|
// базовый фон как в тултипе
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.88)',
|
||||||
|
color: '#fff',
|
||||||
|
fontFamily: 'Benzin-Bold, sans-serif',
|
||||||
|
|
||||||
|
// рамка + неоновая подсветка
|
||||||
|
border: `1px solid ${accent.a2}`,
|
||||||
|
boxShadow: `
|
||||||
|
0 0 1.6vw ${accent.glow},
|
||||||
|
0 0 0.6vw rgba(0, 0, 0, 0.35),
|
||||||
|
inset 0 0 0.6vw rgba(0, 0, 0, 0.45)
|
||||||
|
`,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
|
||||||
|
// внутренний градиентный бордер как у CustomTooltip
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: '1vw',
|
||||||
|
padding: '2px',
|
||||||
|
background: `
|
||||||
|
linear-gradient(
|
||||||
|
135deg,
|
||||||
|
${accent.a1} 0%,
|
||||||
|
${accent.a2} 50%,
|
||||||
|
${accent.a3} 100%
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
WebkitMask:
|
||||||
|
'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
||||||
|
WebkitMaskComposite: 'xor',
|
||||||
|
maskComposite: 'exclude',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// контент поверх ::before
|
||||||
|
'& .MuiAlert-message': {
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
padding: 0,
|
||||||
|
fontSize: '1.5vw',
|
||||||
|
lineHeight: 1.25,
|
||||||
|
},
|
||||||
|
|
||||||
|
// кнопка закрытия
|
||||||
|
'& .MuiAlert-action': {
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 0,
|
||||||
|
marginLeft: '1vw',
|
||||||
|
},
|
||||||
|
'& .MuiIconButton-root': {
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
color: accent.a1,
|
||||||
|
transform: 'scale(1.08)',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/renderer/components/Notifications/CustomTooltip.tsx
Normal file
75
src/renderer/components/Notifications/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;
|
||||||
453
src/renderer/components/OnlinePlayersPanel.tsx
Normal file
453
src/renderer/components/OnlinePlayersPanel.tsx
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
// src/renderer/components/OnlinePlayersPanel.tsx
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Chip,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
TextField,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
fetchActiveServers,
|
||||||
|
fetchOnlinePlayers,
|
||||||
|
fetchPlayer,
|
||||||
|
Server,
|
||||||
|
} from '../api';
|
||||||
|
import { FullScreenLoader } from './FullScreenLoader';
|
||||||
|
import { HeadAvatar } from './HeadAvatar';
|
||||||
|
import { translateServer } from '../utils/serverTranslator';
|
||||||
|
import GradientTextField from './GradientTextField'; // <-- используем ваш градиентный инпут
|
||||||
|
import { NONAME } from 'dns';
|
||||||
|
|
||||||
|
type OnlinePlayerFlat = {
|
||||||
|
username: string;
|
||||||
|
uuid: string;
|
||||||
|
serverId: string;
|
||||||
|
serverName: string;
|
||||||
|
onlineSince: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OnlinePlayersPanelProps {
|
||||||
|
currentUsername: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRADIENT =
|
||||||
|
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSkins();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [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 sx={{ mt: 2, color: '#ff8080', fontFamily: 'Benzin-Bold' }}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onlinePlayers.length) {
|
||||||
|
return (
|
||||||
|
<Typography sx={{ mt: 2, color: 'rgba(255,255,255,0.75)', fontWeight: 700 }}>
|
||||||
|
Сейчас на серверах никого нет.
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalOnline = onlinePlayers.length;
|
||||||
|
|
||||||
|
const controlSx = {
|
||||||
|
minWidth: '16vw',
|
||||||
|
'& .MuiInputLabel-root': {
|
||||||
|
color: 'rgba(255,255,255,0.75)',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
},
|
||||||
|
'& .MuiInputLabel-root.Mui-focused': {
|
||||||
|
color: 'rgba(242,113,33,0.95)',
|
||||||
|
},
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
height: '3.2vw', // <-- ЕДИНАЯ высота
|
||||||
|
borderRadius: '999px',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||||
|
color: 'white',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'rgba(255,255,255,0.14)',
|
||||||
|
},
|
||||||
|
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'rgba(242,113,33,0.55)',
|
||||||
|
},
|
||||||
|
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'rgba(233,64,205,0.65)',
|
||||||
|
borderWidth: '2px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mt: 3,
|
||||||
|
borderRadius: '1.2vw',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.16), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.92)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: '1.8vw',
|
||||||
|
pt: '1.2vw',
|
||||||
|
pb: '1.1vw',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.16), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.92)',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: '1.6vw',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ minWidth: 240 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '1.35vw',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
backgroundImage: GRADIENT,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Игроки онлайн
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.9vw', color: 'rgba(255,255,255,0.70)', fontWeight: 700 }}>
|
||||||
|
Сейчас на серверах: {totalOnline}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: '1vw', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
{/* Select в “нашем” стиле */}
|
||||||
|
<FormControl
|
||||||
|
size="small"
|
||||||
|
sx={controlSx}
|
||||||
|
>
|
||||||
|
<InputLabel>Сервер</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Сервер"
|
||||||
|
value={serverFilter}
|
||||||
|
onChange={(e) => setServerFilter(e.target.value)}
|
||||||
|
MenuProps={{
|
||||||
|
PaperProps: {
|
||||||
|
sx: {
|
||||||
|
bgcolor: 'rgba(10,10,20,0.96)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
borderRadius: '1vw',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
'& .MuiMenuItem-root': {
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
},
|
||||||
|
'& .MuiMenuItem-root.Mui-selected': {
|
||||||
|
backgroundColor: 'rgba(242,113,33,0.16)',
|
||||||
|
},
|
||||||
|
'& .MuiMenuItem-root:hover': {
|
||||||
|
backgroundColor: 'rgba(233,64,205,0.14)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
color: 'white',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
borderRadius: '999px',
|
||||||
|
bgcolor: 'rgba(255,255,255,0.04)',
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
py: '0.7vw',
|
||||||
|
px: '1.2vw',
|
||||||
|
},
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'rgba(255,255,255,0.14)',
|
||||||
|
},
|
||||||
|
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'rgba(242,113,33,0.55)',
|
||||||
|
},
|
||||||
|
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'rgba(233,64,205,0.65)',
|
||||||
|
borderWidth: '2px',
|
||||||
|
},
|
||||||
|
'& .MuiSelect-icon': {
|
||||||
|
color: 'rgba(255,255,255,0.75)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">Все сервера</MenuItem>
|
||||||
|
{servers.map((s) => (
|
||||||
|
<MenuItem key={s.id} value={s.id}>
|
||||||
|
{translateServer(s.name)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* Поиск через ваш GradientTextField */}
|
||||||
|
<Box sx={{ minWidth: '16vw' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Поиск по нику"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
sx={{
|
||||||
|
...controlSx,
|
||||||
|
'& .MuiOutlinedInput-input': {
|
||||||
|
height: '100%',
|
||||||
|
padding: '0 1.2vw', // <-- ТОЧНО ТАКОЙ ЖЕ padding
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '0.9vw',
|
||||||
|
color: 'rgba(255,255,255,0.92)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* <GradientTextField
|
||||||
|
label="Поиск по нику"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
sx={{
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
padding: 'none',
|
||||||
|
fontFamily: 'none',
|
||||||
|
},
|
||||||
|
'& .css-16wblaj-MuiInputBase-input-MuiOutlinedInput-input': {
|
||||||
|
padding: '4px 0 5px',
|
||||||
|
},
|
||||||
|
'& .css-19qnlrw-MuiFormLabel-root-MuiInputLabel-root': {
|
||||||
|
top: '-15px',
|
||||||
|
},
|
||||||
|
|
||||||
|
'& .MuiOutlinedInput-root::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
padding: '0.2vw', // толщина рамки
|
||||||
|
borderRadius: '3.5vw',
|
||||||
|
background: GRADIENT,
|
||||||
|
WebkitMask:
|
||||||
|
'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
||||||
|
WebkitMaskComposite: 'xor',
|
||||||
|
maskComposite: 'exclude',
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/> */}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* list */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: '1.8vw',
|
||||||
|
py: '1.3vw',
|
||||||
|
maxHeight: '35vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.65vw',
|
||||||
|
|
||||||
|
// аккуратный скроллбар (webkit)
|
||||||
|
'&::-webkit-scrollbar': { width: '0.55vw' },
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: 'rgba(255,255,255,0.12)',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb:hover': {
|
||||||
|
background: 'rgba(242,113,33,0.25)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredPlayers.map((p) => {
|
||||||
|
const isMe = p.username === currentUsername;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
key={p.uuid}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
px: '1.1vw',
|
||||||
|
py: '0.75vw',
|
||||||
|
borderRadius: '1.1vw',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1vw',
|
||||||
|
transition: 'transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.01)',
|
||||||
|
borderColor: 'rgba(242,113,33,0.35)',
|
||||||
|
boxShadow: '0 0.8vw 2.4vw rgba(0,0,0,0.45)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: '0.8vw', minWidth: 0 }}>
|
||||||
|
<HeadAvatar skinUrl={skinMap[p.uuid]} size={26} />
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
color: 'rgba(255,255,255,0.92)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.username}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isMe && (
|
||||||
|
<Chip
|
||||||
|
label="Вы"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: '1.55rem',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
fontWeight: 900,
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '999px',
|
||||||
|
backgroundImage: GRADIENT,
|
||||||
|
boxShadow: '0 10px 22px rgba(0,0,0,0.45)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: '0.6vw', flexShrink: 0 }}>
|
||||||
|
<Chip
|
||||||
|
label={translateServer(p.serverName)}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
color: 'rgba(255,255,255,0.88)',
|
||||||
|
background:
|
||||||
|
'linear-gradient(120deg, rgba(242,113,33,0.18), rgba(233,64,205,0.12), rgba(138,35,135,0.16))',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* onlineSince можно потом красиво форматировать */}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
140
src/renderer/components/PageHeader.tsx
Normal file
140
src/renderer/components/PageHeader.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { Box, Typography } from '@mui/material';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useMemo, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface HeaderConfig {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageHeader() {
|
||||||
|
const location = useLocation();
|
||||||
|
const [isAuthed, setIsAuthed] = useState(false);
|
||||||
|
|
||||||
|
const isLaunchPage = location.pathname.startsWith('/launch');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('launcher_config');
|
||||||
|
try {
|
||||||
|
const cfg = saved ? JSON.parse(saved) : null;
|
||||||
|
setIsAuthed(Boolean(cfg?.accessToken)); // или cfg?.uuid/username
|
||||||
|
} catch {
|
||||||
|
setIsAuthed(false);
|
||||||
|
}
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const headerConfig: HeaderConfig | null = useMemo(() => {
|
||||||
|
const path = location.pathname;
|
||||||
|
|
||||||
|
// Страницы без заголовка
|
||||||
|
if (
|
||||||
|
path === '/login' ||
|
||||||
|
path === '/registration' ||
|
||||||
|
path === '/marketplace' ||
|
||||||
|
path === '/profile' ||
|
||||||
|
path.startsWith('/launch')
|
||||||
|
) {
|
||||||
|
return { title: '', subtitle: '', hidden: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/settings') {
|
||||||
|
return {
|
||||||
|
title: 'Настройки',
|
||||||
|
subtitle: 'Персонализация интерфейса и поведения лаунчера',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/news') {
|
||||||
|
return {
|
||||||
|
title: 'Новости',
|
||||||
|
subtitle: 'Последние обновления лаунчера, сервера и ивентов',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/') {
|
||||||
|
return {
|
||||||
|
title: 'Выбор версии клиента',
|
||||||
|
subtitle: 'Выберите установленную версию или добавьте новую сборку',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('/daily')) {
|
||||||
|
return {
|
||||||
|
title: 'Ежедневные награды',
|
||||||
|
subtitle: 'Ежедневный вход на сервер приносит бонусы и полезные награды!',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('/dailyquests')) {
|
||||||
|
return {
|
||||||
|
title: 'Ежедневные задания',
|
||||||
|
subtitle:
|
||||||
|
'Выполняйте ежедневные задания разной сложности и получайте награды!',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('/shop')) {
|
||||||
|
return {
|
||||||
|
title: 'Внутриигровой магазин',
|
||||||
|
subtitle: 'Тратьте свою уникальную виртуальную валюту — Попы!',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('/marketplace')) {
|
||||||
|
return {
|
||||||
|
title: 'Маркетплейс',
|
||||||
|
subtitle: 'Покупайте или продавайте — торговая площадка между игроками',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дефолт
|
||||||
|
return { title: 'test', subtitle: 'test' };
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
// ✅ один общий guard — тут и “hidden”, и “не авторизован”, и launch
|
||||||
|
if (!headerConfig || headerConfig.hidden || !isAuthed || isLaunchPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '85%',
|
||||||
|
mt: '10vh',
|
||||||
|
mb: '2vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{headerConfig.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
mt: 0.5,
|
||||||
|
color: 'rgba(255,255,255,1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{headerConfig.subtitle}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/renderer/components/PlayerPreviewModal.tsx
Normal file
85
src/renderer/components/PlayerPreviewModal.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
// 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
|
||||||
|
sx={{
|
||||||
|
'& .MuiPaper-root': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderRadius: '2vw',
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
<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;
|
||||||
256
src/renderer/components/Profile/DailyRewards.tsx
Normal file
256
src/renderer/components/Profile/DailyRewards.tsx
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
LinearProgress,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { claimDaily, fetchDailyStatus, DailyStatusResponse } from '../../api';
|
||||||
|
import CoinsDisplay from '../CoinsDisplay';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
function formatHHMMSS(totalSeconds: number) {
|
||||||
|
const s = Math.max(0, Math.floor(totalSeconds));
|
||||||
|
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
|
||||||
|
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
|
||||||
|
const ss = String(s % 60).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcRewardByStreak(streak: number) {
|
||||||
|
// ВАЖНО: синхронизируй с бэком. Сейчас у тебя в бэке: 10..50 :contentReference[oaicite:2]{index=2}
|
||||||
|
// Если хочешь 50..100 — поменяй здесь тоже.
|
||||||
|
return Math.min(10 + Math.max(0, streak - 1) * 10, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClaimed?: (coinsAdded: number) => void;
|
||||||
|
onOpenGame?: () => void; // опционально: кнопка "Запустить игру"
|
||||||
|
};
|
||||||
|
|
||||||
|
type DailyStatusCompat = DailyStatusResponse & {
|
||||||
|
was_online_today?: boolean;
|
||||||
|
next_claim_at_utc?: string;
|
||||||
|
next_claim_at_local?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DailyRewards({ onClaimed, onOpenGame }: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [status, setStatus] = useState<DailyStatusCompat | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [success, setSuccess] = useState<string>('');
|
||||||
|
|
||||||
|
const secondsLeft = status?.seconds_to_next ?? 0;
|
||||||
|
const streak = status?.streak ?? 0;
|
||||||
|
|
||||||
|
const wasOnlineToday = status?.was_online_today ?? false; // если бэк не прислал — считаем false
|
||||||
|
const canClaim = (status?.can_claim ?? false) && wasOnlineToday;
|
||||||
|
|
||||||
|
const nextClaimAt =
|
||||||
|
status?.next_claim_at_utc || status?.next_claim_at_local || '';
|
||||||
|
|
||||||
|
const todaysReward = useMemo(() => {
|
||||||
|
const effectiveStreak = canClaim
|
||||||
|
? Math.max(1, streak === 0 ? 1 : streak)
|
||||||
|
: streak;
|
||||||
|
return calcRewardByStreak(effectiveStreak);
|
||||||
|
}, [streak, canClaim]);
|
||||||
|
|
||||||
|
const progressValue = useMemo(() => {
|
||||||
|
const day = 24 * 3600;
|
||||||
|
const remaining = Math.min(day, Math.max(0, secondsLeft));
|
||||||
|
return ((day - remaining) / day) * 100;
|
||||||
|
}, [secondsLeft]);
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const s = (await fetchDailyStatus()) as DailyStatusCompat;
|
||||||
|
setStatus(s);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка загрузки статуса');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setTick((x) => x + 1), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clientSecondsLeft = useMemo(() => {
|
||||||
|
if (!status) return 0;
|
||||||
|
if (canClaim) return 0;
|
||||||
|
return Math.max(0, status.seconds_to_next - tick);
|
||||||
|
}, [status, tick, canClaim]);
|
||||||
|
|
||||||
|
const handleClaim = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
try {
|
||||||
|
const res = await claimDaily();
|
||||||
|
|
||||||
|
if (res.claimed) {
|
||||||
|
const added = res.coins_added ?? 0;
|
||||||
|
setSuccess(`Вы получили ${added} монет!`);
|
||||||
|
if (onClaimed) onClaimed(added);
|
||||||
|
} else {
|
||||||
|
// если бэк вернёт reason=not_online_today — покажем по-человечески
|
||||||
|
if (res.reason === 'not_online_today') {
|
||||||
|
setError(
|
||||||
|
'Чтобы забрать награду — зайдите на сервер сегодня хотя бы на минуту.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(res.reason || 'Награда недоступна');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadStatus();
|
||||||
|
setTick(0);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка при получении награды');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitle = useMemo(() => {
|
||||||
|
if (!status) return '';
|
||||||
|
if (!wasOnlineToday)
|
||||||
|
return 'Награда откроется после входа на сервер сегодня.';
|
||||||
|
if (canClaim) return 'Можно забрать прямо сейчас 🎁';
|
||||||
|
return `До следующей награды: ${formatHHMMSS(clientSecondsLeft)}`;
|
||||||
|
}, [status, wasOnlineToday, canClaim, clientSecondsLeft]);
|
||||||
|
|
||||||
|
const navigateDaily = () => {
|
||||||
|
navigate('/daily');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
background: 'rgba(20,20,20,0.9)',
|
||||||
|
borderRadius: '2vw',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ p: 3 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
mb: 1,
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ежедневная награда
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Alert severity="success" sx={{ mb: 2 }}>
|
||||||
|
{success}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!status ? (
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||||
|
Загружаем статус...
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}
|
||||||
|
>
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.75)' }}>
|
||||||
|
Серия дней: <b>{streak}</b>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: 'rgba(255,255,255,0.75)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Награда: <CoinsDisplay value={todaysReward} size="small" />
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<LinearProgress variant="determinate" value={progressValue} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.85)', mb: 2 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{!wasOnlineToday && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: 'rgba(255,255,255,0.6)',
|
||||||
|
mb: 2,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Зайдите на сервер сегодня — после этого кнопка станет активной.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
disabled={loading || !status.ok || !canClaim}
|
||||||
|
onClick={handleClaim}
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
transition: 'transform 0.3s ease',
|
||||||
|
background:
|
||||||
|
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
borderRadius: '2.5vw',
|
||||||
|
'&:hover': { transform: 'scale(1.03)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Забираем...' : 'Забрать награду'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
onClick={navigateDaily}
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
transition: 'transform 0.3s ease',
|
||||||
|
background:
|
||||||
|
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
borderRadius: '2.5vw',
|
||||||
|
'&:hover': { transform: 'scale(1.03)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ежедневные награды
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Box, Typography, CircularProgress, Avatar } from '@mui/material';
|
import { Box, Typography, Avatar } from '@mui/material';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface ServerStatusProps {
|
interface ServerStatusProps {
|
||||||
@ -104,10 +104,7 @@ const ServerStatus = ({
|
|||||||
?
|
?
|
||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
|
{serverStatus.error ? (
|
||||||
{serverStatus.loading ? (
|
|
||||||
<CircularProgress size={20} />
|
|
||||||
) : serverStatus.error ? (
|
|
||||||
<Typography color="error">Ошибка загрузки</Typography>
|
<Typography color="error">Ошибка загрузки</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Typography sx={{ fontWeight: 'bold' }}>
|
<Typography sx={{ fontWeight: 'bold' }}>
|
||||||
|
|||||||
93
src/renderer/components/Settings/SettingsModal.tsx
Normal file
93
src/renderer/components/Settings/SettingsModal.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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: (updater: (prev: { memory: number; preserveFiles: string[] }) => {
|
||||||
|
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((prev) => ({ ...prev, preserveFiles: selected }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="body1" sx={{ color: 'white' }}>
|
||||||
|
Оперативная память выделенная для Minecraft
|
||||||
|
</Typography>
|
||||||
|
<MemorySlider
|
||||||
|
memory={config.memory}
|
||||||
|
onChange={(_, value) => {
|
||||||
|
const next = Array.isArray(value) ? value[0] : value;
|
||||||
|
onConfigChange((prev) => ({ ...prev, memory: next }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
onClick={() => {
|
||||||
|
onSave();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '3vw',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsModal;
|
||||||
241
src/renderer/components/ShopItem.tsx
Normal file
241
src/renderer/components/ShopItem.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
// 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.01)',
|
||||||
|
boxShadow: '0 20px 20px rgba(242,113,33,0.1)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Градиентный свет сверху */}
|
||||||
|
<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.05)',
|
||||||
|
},
|
||||||
|
transition: 'all 0.5s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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.02)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{type === 'cape' && imageUrl && (
|
||||||
|
<CapePreviewModal
|
||||||
|
open={previewOpen}
|
||||||
|
onClose={() => setPreviewOpen(false)}
|
||||||
|
capeUrl={imageUrl}
|
||||||
|
skinUrl={playerSkinUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/renderer/components/SkinViewer.tsx
Normal file
132
src/renderer/components/SkinViewer.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface SkinViewerProps {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
skinUrl?: string;
|
||||||
|
capeUrl?: string;
|
||||||
|
walkingSpeed?: number;
|
||||||
|
autoRotate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SKIN =
|
||||||
|
'https://static.planetminecraft.com/files/resource_media/skin/original-steve-15053860.png';
|
||||||
|
|
||||||
|
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);
|
||||||
|
const animRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// 1) Инициализируем viewer ОДИН РАЗ
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
if (!canvasRef.current || viewerRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const skinview3d = await import('skinview3d');
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
const viewer = new skinview3d.SkinViewer({
|
||||||
|
canvas: canvasRef.current,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// базовая настройка
|
||||||
|
viewer.autoRotate = autoRotate;
|
||||||
|
|
||||||
|
// анимация ходьбы
|
||||||
|
const walking = new skinview3d.WalkingAnimation();
|
||||||
|
walking.speed = walkingSpeed;
|
||||||
|
viewer.animation = walking;
|
||||||
|
|
||||||
|
viewerRef.current = viewer;
|
||||||
|
animRef.current = walking;
|
||||||
|
|
||||||
|
// выставляем ресурсы сразу
|
||||||
|
const finalSkin = skinUrl?.trim() ? skinUrl : DEFAULT_SKIN;
|
||||||
|
await viewer.loadSkin(finalSkin);
|
||||||
|
|
||||||
|
if (capeUrl?.trim()) {
|
||||||
|
await viewer.loadCape(capeUrl);
|
||||||
|
} else {
|
||||||
|
viewer.cape = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка при инициализации skinview3d:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
if (viewerRef.current) {
|
||||||
|
viewerRef.current.dispose();
|
||||||
|
viewerRef.current = null;
|
||||||
|
animRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// ⚠️ пустой deps — создаём один раз
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 2) Обновляем размеры (не пересоздаём viewer)
|
||||||
|
useEffect(() => {
|
||||||
|
const viewer = viewerRef.current;
|
||||||
|
if (!viewer) return;
|
||||||
|
viewer.width = width;
|
||||||
|
viewer.height = height;
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
|
// 3) Обновляем автоповорот
|
||||||
|
useEffect(() => {
|
||||||
|
const viewer = viewerRef.current;
|
||||||
|
if (!viewer) return;
|
||||||
|
viewer.autoRotate = autoRotate;
|
||||||
|
}, [autoRotate]);
|
||||||
|
|
||||||
|
// 4) Обновляем скорость анимации
|
||||||
|
useEffect(() => {
|
||||||
|
const walking = animRef.current;
|
||||||
|
if (!walking) return;
|
||||||
|
walking.speed = walkingSpeed;
|
||||||
|
}, [walkingSpeed]);
|
||||||
|
|
||||||
|
// 5) Обновляем скин
|
||||||
|
useEffect(() => {
|
||||||
|
const viewer = viewerRef.current;
|
||||||
|
if (!viewer) return;
|
||||||
|
|
||||||
|
const finalSkin = skinUrl?.trim() ? skinUrl : DEFAULT_SKIN;
|
||||||
|
|
||||||
|
// защита от кеша: добавим “bust” только если URL уже имеет query — не обязательно, но помогает
|
||||||
|
const url = finalSkin.includes('?') ? `${finalSkin}&t=${Date.now()}` : `${finalSkin}?t=${Date.now()}`;
|
||||||
|
|
||||||
|
viewer.loadSkin(url).catch((e: any) => console.error('loadSkin error:', e));
|
||||||
|
}, [skinUrl]);
|
||||||
|
|
||||||
|
// 6) Обновляем плащ
|
||||||
|
useEffect(() => {
|
||||||
|
const viewer = viewerRef.current;
|
||||||
|
if (!viewer) return;
|
||||||
|
|
||||||
|
if (capeUrl?.trim()) {
|
||||||
|
const url = capeUrl.includes('?') ? `${capeUrl}&t=${Date.now()}` : `${capeUrl}?t=${Date.now()}`;
|
||||||
|
viewer.loadCape(url).catch((e: any) => console.error('loadCape error:', e));
|
||||||
|
} else {
|
||||||
|
viewer.cape = null;
|
||||||
|
}
|
||||||
|
}, [capeUrl]);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} width={width} height={height} style={{ display: 'block' }} />;
|
||||||
|
}
|
||||||
@ -1,8 +1,26 @@
|
|||||||
import { Box, Button, Typography } from '@mui/material';
|
import {
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
Box,
|
||||||
import MinimizeIcon from '@mui/icons-material/Minimize';
|
Button,
|
||||||
import { useLocation } from 'react-router-dom';
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
Typography,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Divider,
|
||||||
|
} 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, useCallback } from 'react';
|
||||||
|
import CustomTooltip from './Notifications/CustomTooltip';
|
||||||
|
import CoinsDisplay from './CoinsDisplay';
|
||||||
|
import { HeadAvatar } from './HeadAvatar';
|
||||||
|
import { fetchPlayer } from './../api';
|
||||||
|
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
|
||||||
|
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
|
||||||
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electron: {
|
electron: {
|
||||||
@ -18,61 +36,421 @@ declare global {
|
|||||||
// Определяем пропсы
|
// Определяем пропсы
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
onRegister?: () => void; // Опционально, если нужен обработчик регистрации
|
onRegister?: () => void; // Опционально, если нужен обработчик регистрации
|
||||||
|
username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopBar({ onRegister }: TopBarProps) {
|
export default function TopBar({ onRegister, username }: TopBarProps) {
|
||||||
// Получаем текущий путь
|
// Получаем текущий путь
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isLoginPage = location.pathname === '/login';
|
const isLoginPage = location.pathname === '/login';
|
||||||
|
const [isAuthed, setIsAuthed] = useState(false);
|
||||||
|
const isLaunchPage = location.pathname.startsWith('/launch');
|
||||||
|
const isRegistrationPage = location.pathname === '/registration';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const tabsWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const tabsRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const updateGradientVars = useCallback(() => {
|
||||||
|
const root = tabsRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const tabsRect = root.getBoundingClientRect();
|
||||||
|
const active = root.querySelector<HTMLElement>('.MuiTab-root.Mui-selected');
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
const activeRect = active.getBoundingClientRect();
|
||||||
|
const x = activeRect.left - tabsRect.left;
|
||||||
|
|
||||||
|
root.style.setProperty('--tabs-w', `${tabsRect.width}px`);
|
||||||
|
root.style.setProperty('--active-x', `${x}px`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [skinUrl, setSkinUrl] = useState<string>('');
|
||||||
|
const [skinVersion, setSkinVersion] = useState(0);
|
||||||
|
const [avatarAnchorEl, setAvatarAnchorEl] = useState<null | HTMLElement>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const path = location.pathname || '';
|
||||||
|
const isAuthPage =
|
||||||
|
path.startsWith('/login') || path.startsWith('/registration');
|
||||||
|
|
||||||
|
const TAB_ROUTES: Array<{
|
||||||
|
value: number;
|
||||||
|
match: (p: string) => boolean;
|
||||||
|
to: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
match: (p) => p === '/news',
|
||||||
|
to: '/news',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
match: (p) => p === '/',
|
||||||
|
to: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 2,
|
||||||
|
match: (p) => p.startsWith('/shop'),
|
||||||
|
to: '/shop',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 3,
|
||||||
|
match: (p) => p.startsWith('/marketplace'),
|
||||||
|
to: '/marketplace',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedTab =
|
||||||
|
TAB_ROUTES.find((r) => r.match(location.pathname))?.value ?? false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateGradientVars();
|
||||||
|
window.addEventListener('resize', updateGradientVars);
|
||||||
|
return () => window.removeEventListener('resize', updateGradientVars);
|
||||||
|
}, [updateGradientVars, selectedTab, location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('launcher_config');
|
||||||
|
try {
|
||||||
|
const cfg = saved ? JSON.parse(saved) : null;
|
||||||
|
setIsAuthed(Boolean(cfg?.accessToken)); // или cfg?.uuid/username — как у тебя принято
|
||||||
|
} catch {
|
||||||
|
setIsAuthed(false);
|
||||||
|
}
|
||||||
|
}, [location.pathname]); // можно и без dependency, но так надёжнее при logout/login
|
||||||
|
|
||||||
|
const avatarMenuOpen = Boolean(avatarAnchorEl);
|
||||||
|
|
||||||
|
const handleAvatarClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAvatarAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarMenuClose = () => {
|
||||||
|
setAvatarAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (location.pathname === '/news') {
|
||||||
|
// setValue(0);
|
||||||
|
// setActivePage('news');
|
||||||
|
// } else if (location.pathname === '/') {
|
||||||
|
// setValue(1);
|
||||||
|
// setActivePage('versions');
|
||||||
|
// } else if (location.pathname.startsWith('/shop')) {
|
||||||
|
// setValue(3);
|
||||||
|
// setActivePage('shop');
|
||||||
|
// } else if (location.pathname.startsWith('/marketplace')) {
|
||||||
|
// setValue(4);
|
||||||
|
// setActivePage('marketplace');
|
||||||
|
// } else {
|
||||||
|
// // любые страницы не из TopBar: /profile, /daily, /dailyquests, и т.д.
|
||||||
|
// setValue(false);
|
||||||
|
// setActivePage('');
|
||||||
|
// }
|
||||||
|
// }, [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;
|
||||||
|
|
||||||
|
requestAnimationFrame(updateGradientVars);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 tabBaseSx = [{ fontSize: '0.7em' }, theme.launcher.topbar.tabBase];
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('launcher_config');
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSkin = useCallback(async () => {
|
||||||
|
if (!isAuthed) {
|
||||||
|
setSkinUrl('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedConfig = localStorage.getItem('launcher_config');
|
||||||
|
if (!savedConfig) return;
|
||||||
|
|
||||||
|
let cfg: any = null;
|
||||||
|
try {
|
||||||
|
cfg = JSON.parse(savedConfig);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = cfg?.uuid;
|
||||||
|
if (!uuid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const player = await fetchPlayer(uuid);
|
||||||
|
setSkinUrl(player.skin_url || '');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Не удалось получить скин:', e);
|
||||||
|
setSkinUrl('');
|
||||||
|
}
|
||||||
|
}, [isAuthed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSkin();
|
||||||
|
}, [loadSkin, location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
setSkinVersion((v) => v + 1);
|
||||||
|
loadSkin();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('skin-updated', handler as EventListener);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener('skin-updated', handler as EventListener);
|
||||||
|
}, [loadSkin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
requestAnimationFrame(updateGradientVars);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('settings-updated', handler as EventListener);
|
||||||
|
return () => window.removeEventListener('settings-updated', handler as EventListener);
|
||||||
|
}, [updateGradientVars]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
className={isAuthPage ? undefined : 'glass-ui'}
|
||||||
display: 'flex',
|
sx={[
|
||||||
position: 'absolute',
|
{
|
||||||
top: 0,
|
display: 'flex',
|
||||||
left: 0,
|
position: 'fixed',
|
||||||
right: 0,
|
top: 0,
|
||||||
height: '50px',
|
left: 0,
|
||||||
zIndex: 1000,
|
right: 0,
|
||||||
width: '100%',
|
height: '8vh',
|
||||||
WebkitAppRegion: 'drag',
|
zIndex: 1000,
|
||||||
overflow: 'hidden',
|
width: '100%',
|
||||||
justifyContent: 'flex-end', // Всё содержимое справа
|
WebkitAppRegion: 'drag',
|
||||||
}}
|
overflow: 'hidden',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
theme.launcher.topbar.firstBox,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{/* Правая часть со всеми кнопками */}
|
{/* Левая часть */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
WebkitAppRegion: 'no-drag',
|
WebkitAppRegion: 'no-drag',
|
||||||
gap: '2vw',
|
gap: '2vw',
|
||||||
padding: '1em',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
marginLeft: '1vw',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Кнопка регистрации, если на странице логина */}
|
{(isLaunchPage || isRegistrationPage) && (
|
||||||
{isLoginPage && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
onClick={() => handleLaunchPage()}
|
||||||
onClick={() => onRegister && onRegister()}
|
sx={[
|
||||||
sx={{
|
{
|
||||||
width: '10em',
|
width: '3em',
|
||||||
height: '3em',
|
height: '3em',
|
||||||
borderRadius: '1.5vw',
|
borderRadius: '50%',
|
||||||
color: 'white',
|
minWidth: 'unset',
|
||||||
backgroundImage: 'linear-gradient(to right, #7BB8FF, #FFB7ED)',
|
minHeight: 'unset',
|
||||||
border: 'unset',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundImage: 'linear-gradient(to right, #6AA8EE, #EEA7DD)',
|
|
||||||
},
|
},
|
||||||
boxShadow: '0.5em 0.5em 0.5em 0px #00000040 inset',
|
theme.launcher.topbar.backButton,
|
||||||
}}
|
]}
|
||||||
>
|
>
|
||||||
Регистрация
|
<ArrowBackRoundedIcon />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{isAuthed && !isLaunchPage && (
|
||||||
|
<Box
|
||||||
|
ref={tabsWrapperRef}
|
||||||
|
onWheel={handleTabsWheel}
|
||||||
|
// старый вариант
|
||||||
|
sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
...theme.launcher.topbar.tabsBox,
|
||||||
|
// '& .MuiTabs-indicator': {
|
||||||
|
// backgroundColor: 'rgba(255, 77, 77, 1)',
|
||||||
|
// },
|
||||||
|
}}
|
||||||
|
// sx={{
|
||||||
|
// borderBottom: 'none',
|
||||||
|
// borderRadius: '2vw',
|
||||||
|
// px: '0.6vw',
|
||||||
|
// py: '0.4vw',
|
||||||
|
// background: 'rgba(0,0,0,0.35)',
|
||||||
|
// border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
// boxShadow: '0 8px 20px rgba(0,0,0,0.25)',
|
||||||
|
// '& .MuiTabs-indicator': {
|
||||||
|
// height: '100%',
|
||||||
|
// borderRadius: '1.6vw',
|
||||||
|
// background:
|
||||||
|
// 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||||
|
// opacity: 0.18,
|
||||||
|
// },
|
||||||
|
// }}
|
||||||
|
>
|
||||||
|
<CustomTooltip
|
||||||
|
title={
|
||||||
|
'Покрути колесиком мыши чтобы увидеть остальные элементы меню'
|
||||||
|
}
|
||||||
|
arrow
|
||||||
|
placement="bottom"
|
||||||
|
TransitionProps={{ timeout: 100 }}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
ref={tabsRootRef}
|
||||||
|
value={selectedTab}
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
const route = TAB_ROUTES.find((r) => r.value === newValue);
|
||||||
|
if (route) navigate(route.to);
|
||||||
|
}}
|
||||||
|
aria-label="basic tabs example"
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons={false}
|
||||||
|
disableRipple={true}
|
||||||
|
sx={{
|
||||||
|
...theme.launcher.topbar.tabs,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
label="Новости"
|
||||||
|
disableRipple={true}
|
||||||
|
sx={[
|
||||||
|
...tabBaseSx,
|
||||||
|
selectedTab === 0 ? theme.launcher.topbar.tabActive : null,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label="Версии"
|
||||||
|
disableRipple={true}
|
||||||
|
sx={[
|
||||||
|
...tabBaseSx,
|
||||||
|
selectedTab === 1 ? theme.launcher.topbar.tabActive : null,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label="Магазин"
|
||||||
|
disableRipple={true}
|
||||||
|
sx={[
|
||||||
|
...tabBaseSx,
|
||||||
|
selectedTab === 2 ? theme.launcher.topbar.tabActive : null,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label="Рынок"
|
||||||
|
disableRipple={true}
|
||||||
|
sx={[
|
||||||
|
...tabBaseSx,
|
||||||
|
selectedTab === 3 ? theme.launcher.topbar.tabActive : null,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</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 && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: '1vw' }}>
|
||||||
|
<HeadAvatar
|
||||||
|
skinUrl={skinUrl}
|
||||||
|
size={44}
|
||||||
|
version={skinVersion}
|
||||||
|
style={{
|
||||||
|
borderRadius: '3vw',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={handleAvatarClick}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{/* Кнопка регистрации, если на странице логина */}
|
||||||
|
{!isLoginPage && !isRegistrationPage && username && (
|
||||||
|
<CoinsDisplay
|
||||||
|
username={username}
|
||||||
|
size="medium"
|
||||||
|
autoUpdate={true}
|
||||||
|
showTooltip={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Кнопки управления окном */}
|
{/* Кнопки управления окном */}
|
||||||
<Button
|
<Button
|
||||||
@ -85,9 +463,20 @@ export default function TopBar({ onRegister }: TopBarProps) {
|
|||||||
width: '3em',
|
width: '3em',
|
||||||
height: '3em',
|
height: '3em',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
...theme.launcher.topbar.windowControlButton,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MinimizeIcon sx={{ color: 'white' }} />
|
<svg
|
||||||
|
className="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium"
|
||||||
|
focusable="false"
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M 7 19 h 10 c 0.55 0 1 0.45 1 1 s -0.45 1 -1 1 H 7 c -0.55 0 -1 -0.45 -1 -1 s 0.45 -1 1 -1"
|
||||||
|
fill={theme.launcher.topbar.windowControlIcon.color}
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -99,11 +488,155 @@ export default function TopBar({ onRegister }: TopBarProps) {
|
|||||||
width: '3em',
|
width: '3em',
|
||||||
height: '3em',
|
height: '3em',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
...theme.launcher.topbar.windowControlButton,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CloseIcon sx={{ color: 'white' }} />
|
<CloseRoundedIcon
|
||||||
|
sx={{ color: theme.launcher.topbar.windowControlIcon.color }}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Menu
|
||||||
|
anchorEl={avatarAnchorEl}
|
||||||
|
open={avatarMenuOpen}
|
||||||
|
onClose={handleAvatarMenuClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
mt: '0.5vw',
|
||||||
|
borderRadius: '1vw',
|
||||||
|
minWidth: '16vw',
|
||||||
|
...theme.launcher.topbar.menuPaper,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ===== 1 строка: аватар + ник + валюта ===== */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'auto 1fr',
|
||||||
|
gap: '1.5vw',
|
||||||
|
alignItems: 'center',
|
||||||
|
px: '2vw',
|
||||||
|
py: '0.8vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HeadAvatar
|
||||||
|
skinUrl={skinUrl}
|
||||||
|
size={40}
|
||||||
|
version={skinVersion}
|
||||||
|
style={{ borderRadius: '3vw' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Typography
|
||||||
|
sx={[{ fontSize: '2vw' }, theme.launcher.topbar.menuUsername]}
|
||||||
|
>
|
||||||
|
{username || 'Игрок'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<CoinsDisplay
|
||||||
|
username={username}
|
||||||
|
size="medium"
|
||||||
|
autoUpdate={true}
|
||||||
|
showTooltip={false}
|
||||||
|
sx={{
|
||||||
|
border: 'none',
|
||||||
|
padding: '0vw',
|
||||||
|
}}
|
||||||
|
backgroundColor={'rgba(0, 0, 0, 0)'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: '0.4vw', ...theme.launcher.topbar.menuDivider }} />
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleAvatarMenuClose();
|
||||||
|
navigate('/profile');
|
||||||
|
}}
|
||||||
|
sx={[
|
||||||
|
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
|
||||||
|
theme.launcher.topbar.menuItem,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<PersonIcon sx={{ fontSize: '2vw' }} /> Профиль
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
{/* ===== 2 строка: ежедневные задания ===== */}
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleAvatarMenuClose();
|
||||||
|
navigate('/dailyquests');
|
||||||
|
}}
|
||||||
|
sx={[
|
||||||
|
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
|
||||||
|
theme.launcher.topbar.menuItem,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CalendarMonthIcon sx={{ fontSize: '2vw' }} /> Ежедневные задания
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
{/* ===== 3 строка: ежедневная награда ===== */}
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleAvatarMenuClose();
|
||||||
|
navigate('/daily');
|
||||||
|
}}
|
||||||
|
sx={[
|
||||||
|
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
|
||||||
|
theme.launcher.topbar.menuItem,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<EmojiEventsIcon sx={{ fontSize: '2vw' }} /> Ежедневная награда
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleAvatarMenuClose();
|
||||||
|
navigate('/settings');
|
||||||
|
}}
|
||||||
|
sx={[
|
||||||
|
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
|
||||||
|
theme.launcher.topbar.menuItem,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<SettingsIcon sx={{ fontSize: '2vw' }} /> Настройки
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<Divider sx={{ my: '0.4vw', ...theme.launcher.topbar.menuDivider }} />
|
||||||
|
{!isLoginPage && !isRegistrationPage && username && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => {
|
||||||
|
handleAvatarMenuClose();
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
width: '8vw',
|
||||||
|
height: '3vw',
|
||||||
|
fontSize: '1.2vw',
|
||||||
|
m: '0 0 0 18vw',
|
||||||
|
},
|
||||||
|
theme.launcher.topbar.logoutButton,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ↓↓↓ дальше ты сам добавишь пункты ↓↓↓ */}
|
||||||
|
</Menu>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,8 @@ export default function PopaPopa() {
|
|||||||
<Typography
|
<Typography
|
||||||
variant="h3"
|
variant="h3"
|
||||||
sx={{
|
sx={{
|
||||||
background: '-webkit-linear-gradient(200.96deg, #88BCFF, #FD71FF)',
|
backgroundImage: 'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 30%, rgb(138,35,135) 100%)',
|
||||||
|
// background: '-webkit-linear-gradient(200.96deg, #88BCFF, #FD71FF)',
|
||||||
WebkitBackgroundClip: 'text',
|
WebkitBackgroundClip: 'text',
|
||||||
WebkitTextFillColor: 'transparent',
|
WebkitTextFillColor: 'transparent',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,9 +1,18 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
|
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||||
|
import { defaultTheme } from '../theme/themes'; // <-- поправь путь, если themes.ts лежит в другом месте
|
||||||
|
|
||||||
const container = document.getElementById('root') as HTMLElement;
|
const container = document.getElementById('root') as HTMLElement;
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
root.render(<App />);
|
|
||||||
|
root.render(
|
||||||
|
<ThemeProvider theme={defaultTheme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
// calling IPC exposed from preload script
|
// calling IPC exposed from preload script
|
||||||
window.electron?.ipcRenderer.once('ipc-example', (arg) => {
|
window.electron?.ipcRenderer.once('ipc-example', (arg) => {
|
||||||
|
|||||||
389
src/renderer/pages/DailyQuests.tsx
Normal file
389
src/renderer/pages/DailyQuests.tsx
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
// src/renderer/pages/DailyQuests.tsx
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
LinearProgress,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import CoinsDisplay from '../components/CoinsDisplay';
|
||||||
|
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||||
|
import { claimDailyQuest, fetchDailyQuestsStatus, DailyQuestsStatusResponse } from '../api';
|
||||||
|
|
||||||
|
function formatHHMMSS(totalSeconds: number) {
|
||||||
|
const s = Math.max(0, Math.floor(totalSeconds));
|
||||||
|
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
|
||||||
|
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
|
||||||
|
const ss = String(s % 60).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Quest = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
event?: string;
|
||||||
|
target?: string;
|
||||||
|
required: number;
|
||||||
|
progress: number;
|
||||||
|
reward: number;
|
||||||
|
status: 'active' | 'completed' | 'claimed';
|
||||||
|
claimed_at?: string;
|
||||||
|
completed_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DailyQuestsStatusCompat = DailyQuestsStatusResponse & {
|
||||||
|
was_online_today?: boolean;
|
||||||
|
seconds_to_next?: number;
|
||||||
|
next_reset_at_utc?: string;
|
||||||
|
next_reset_at_local?: string;
|
||||||
|
quests?: Quest[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusChip(status: Quest['status']) {
|
||||||
|
if (status === 'claimed')
|
||||||
|
return <Chip size="small" label="Получено" sx={{ bgcolor: 'rgba(156,255,198,0.15)', color: 'rgba(156,255,198,0.95)', fontWeight: 800 }} />;
|
||||||
|
if (status === 'completed')
|
||||||
|
return <Chip size="small" label="Выполнено" sx={{ bgcolor: 'rgba(242,113,33,0.18)', color: 'rgba(242,113,33,0.95)', fontWeight: 800 }} />;
|
||||||
|
return <Chip size="small" label="В процессе" sx={{ bgcolor: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.85)', fontWeight: 800 }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DailyQuests() {
|
||||||
|
const [status, setStatus] = useState<DailyQuestsStatusCompat | null>(null);
|
||||||
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
|
const [actionLoadingKey, setActionLoadingKey] = useState<string>('');
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [success, setSuccess] = useState<string>('');
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const s = (await fetchDailyQuestsStatus()) as DailyQuestsStatusCompat;
|
||||||
|
setStatus(s);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка загрузки ежедневных заданий');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setPageLoading(true);
|
||||||
|
await loadStatus();
|
||||||
|
setPageLoading(false);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setTick((x) => x + 1), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const wasOnlineToday = status?.was_online_today ?? false;
|
||||||
|
|
||||||
|
const clientSecondsLeft = useMemo(() => {
|
||||||
|
if (!status) return 0;
|
||||||
|
return Math.max(0, (status.seconds_to_next ?? 0) - tick);
|
||||||
|
}, [status, tick]);
|
||||||
|
|
||||||
|
const subtitle = useMemo(() => {
|
||||||
|
if (!status) return '';
|
||||||
|
if (!wasOnlineToday) return 'Награды откроются после входа на сервер сегодня.';
|
||||||
|
return `До обновления заданий: ${formatHHMMSS(clientSecondsLeft)}`;
|
||||||
|
}, [status, wasOnlineToday, clientSecondsLeft]);
|
||||||
|
|
||||||
|
const quests: Quest[] = useMemo(() => (status?.quests ?? []) as Quest[], [status]);
|
||||||
|
const totalRewardLeft = useMemo(() => {
|
||||||
|
// сколько ещё можно забрать сегодня (completed, но не claimed)
|
||||||
|
return quests
|
||||||
|
.filter((q) => q.status === 'completed')
|
||||||
|
.reduce((sum, q) => sum + (q.reward ?? 0), 0);
|
||||||
|
}, [quests]);
|
||||||
|
|
||||||
|
const handleClaim = async (questKey: string) => {
|
||||||
|
setActionLoadingKey(questKey);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
try {
|
||||||
|
const res = await claimDailyQuest(questKey);
|
||||||
|
|
||||||
|
if (res.claimed) {
|
||||||
|
const added = res.coins_added ?? 0;
|
||||||
|
setSuccess(`Вы получили ${added} монет!`);
|
||||||
|
} else {
|
||||||
|
if (res.reason === 'not_online_today') {
|
||||||
|
setError('Чтобы забрать награду — зайдите на сервер сегодня хотя бы на минуту.');
|
||||||
|
} else if (res.reason === 'not_completed') {
|
||||||
|
setError('Сначала выполните задание, затем заберите награду.');
|
||||||
|
} else if (res.reason === 'already_claimed') {
|
||||||
|
setError('Награда уже получена.');
|
||||||
|
} else {
|
||||||
|
setError(res.message || res.reason || 'Награда недоступна');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadStatus();
|
||||||
|
setTick(0);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка при получении награды');
|
||||||
|
} finally {
|
||||||
|
setActionLoadingKey('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pageLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: '10vh' }}>
|
||||||
|
<FullScreenLoader fullScreen={false} message="Загрузка ежедневных заданий..." />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ width: '85vw', height: '100%', paddingBottom: '5vh' }}>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '1.2vw',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.18), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.14), transparent 55%), rgba(10,10,20,0.94)',
|
||||||
|
boxShadow: '0 1.2vw 3.8vw rgba(0,0,0,0.55)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: '76vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* sticky header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 5,
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.18), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.14), transparent 55%), rgba(10,10,20,0.94)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ px: '2vw', pt: '1.2vh' }}>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 1.5 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Alert severity="success" sx={{ mb: 1.5 }}>
|
||||||
|
{success}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: '2vw',
|
||||||
|
pb: '1.5vw',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '2vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.6 }}>
|
||||||
|
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.70)', fontWeight: 700 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.2}>
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.75)', fontWeight: 800 }}>
|
||||||
|
Можно забрать сегодня:
|
||||||
|
</Typography>
|
||||||
|
<CoinsDisplay value={totalRewardLeft} size="small" />
|
||||||
|
<Button
|
||||||
|
disableRipple
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
borderRadius: '2.5vw',
|
||||||
|
fontSize: '1vw',
|
||||||
|
px: '3vw',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
borderColor: 'rgba(255,255,255,0.25)',
|
||||||
|
color: '#fff',
|
||||||
|
'&:hover': { borderColor: 'rgba(242,113,33,0.9)' },
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setTick(0);
|
||||||
|
loadStatus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* content */}
|
||||||
|
<Box sx={{ px: '2vw', py: '2vh', overflowY: 'auto', flex: 1 }}>
|
||||||
|
{!wasOnlineToday && (
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
icon={false}
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
borderRadius: '1.1vw',
|
||||||
|
px: '1.4vw',
|
||||||
|
py: '1.1vw',
|
||||||
|
color: 'rgba(255,255,255,0.90)',
|
||||||
|
fontWeight: 800,
|
||||||
|
bgcolor: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.45)',
|
||||||
|
'& .MuiAlert-message': {
|
||||||
|
padding: 0,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
'&:before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 12% 30%, rgba(242,113,33,0.22), transparent 60%), radial-gradient(circle at 85% 0%, rgba(233,64,205,0.14), transparent 55%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
'&:after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '0.35vw',
|
||||||
|
background: 'linear-gradient(180deg, #F27121 0%, #E940CD 65%, #8A2387 100%)',
|
||||||
|
opacity: 0.95,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ position: 'relative', zIndex: 1, color: 'rgba(255,255,255,0.90)', fontWeight: 800 }}>
|
||||||
|
Зайдите на сервер сегодня, чтобы открыть получение наград за квесты.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{quests.length === 0 ? (
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.75)', mt: '6vh' }}>
|
||||||
|
На сегодня заданий нет.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={1.6}>
|
||||||
|
{quests.map((q) => {
|
||||||
|
const req = Math.max(1, q.required ?? 1);
|
||||||
|
const prog = Math.max(0, q.progress ?? 0);
|
||||||
|
const pct = Math.min(100, (prog / req) * 100);
|
||||||
|
|
||||||
|
const canClaim = wasOnlineToday && q.status === 'completed';
|
||||||
|
const disabled = !canClaim || actionLoadingKey === q.key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
key={q.key}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: '1.4vw',
|
||||||
|
borderRadius: '1.1vw',
|
||||||
|
bgcolor: 'rgba(255,255,255,0.05)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: '1.25vw',
|
||||||
|
lineHeight: 1.15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{q.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.65)', fontWeight: 700, mt: 0.6 }}>
|
||||||
|
Прогресс: {Math.min(prog, req)}/{req}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
{statusChip(q.status)}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<CoinsDisplay value={q.reward ?? 0} size="small" />
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={pct}
|
||||||
|
sx={{
|
||||||
|
height: '0.75vw',
|
||||||
|
borderRadius: '999px',
|
||||||
|
bgcolor: 'rgba(255,255,255,0.08)',
|
||||||
|
'& .MuiLinearProgress-bar': {
|
||||||
|
background:
|
||||||
|
'linear-gradient(90deg, rgba(242,113,33,1) 0%, rgba(233,64,205,1) 55%, rgba(138,35,135,1) 100%)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
disableRipple
|
||||||
|
variant="contained"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => handleClaim(q.key)}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '2.5vw',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
background:
|
||||||
|
canClaim
|
||||||
|
? 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)'
|
||||||
|
: 'rgba(255,255,255,0.10)',
|
||||||
|
color: '#fff',
|
||||||
|
'&:hover': {
|
||||||
|
transform: canClaim ? 'scale(1.01)' : 'none',
|
||||||
|
},
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{q.status === 'claimed'
|
||||||
|
? 'Получено'
|
||||||
|
: q.status === 'completed'
|
||||||
|
? actionLoadingKey === q.key
|
||||||
|
? 'Получаем...'
|
||||||
|
: 'Забрать'
|
||||||
|
: 'В процессе'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
722
src/renderer/pages/DailyReward.tsx
Normal file
722
src/renderer/pages/DailyReward.tsx
Normal file
@ -0,0 +1,722 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
|
ButtonBase,
|
||||||
|
Divider,
|
||||||
|
LinearProgress,
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
|
||||||
|
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
|
||||||
|
import TodayRoundedIcon from '@mui/icons-material/TodayRounded';
|
||||||
|
import CustomTooltip from '../components/Notifications/CustomTooltip';
|
||||||
|
import CoinsDisplay from '../components/CoinsDisplay';
|
||||||
|
import {
|
||||||
|
claimDaily,
|
||||||
|
fetchDailyStatus,
|
||||||
|
DailyStatusResponse,
|
||||||
|
fetchDailyClaimDays,
|
||||||
|
} from '../api';
|
||||||
|
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||||
|
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||||
|
|
||||||
|
const pulseGradient = {
|
||||||
|
'@keyframes pulseGlow': {
|
||||||
|
'0%': {
|
||||||
|
opacity: 0.35,
|
||||||
|
transform: 'scale(0.9)',
|
||||||
|
},
|
||||||
|
'50%': {
|
||||||
|
opacity: 0.7,
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: 0.35,
|
||||||
|
transform: 'scale(0.9)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const RU_MONTHS = [
|
||||||
|
'Январь',
|
||||||
|
'Февраль',
|
||||||
|
'Март',
|
||||||
|
'Апрель',
|
||||||
|
'Май',
|
||||||
|
'Июнь',
|
||||||
|
'Июль',
|
||||||
|
'Август',
|
||||||
|
'Сентябрь',
|
||||||
|
'Октябрь',
|
||||||
|
'Ноябрь',
|
||||||
|
'Декабрь',
|
||||||
|
];
|
||||||
|
const RU_WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||||
|
|
||||||
|
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const keyOf = (d: Date) =>
|
||||||
|
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||||
|
const startOfDay = (d: Date) =>
|
||||||
|
new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
const isSameDay = (a: Date, b: Date) =>
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate();
|
||||||
|
const weekdayMonFirst = (date: Date) => (date.getDay() + 6) % 7;
|
||||||
|
|
||||||
|
const EKATERINBURG_TZ = 'Asia/Yekaterinburg';
|
||||||
|
|
||||||
|
function keyOfInTZ(date: Date, timeZone: string) {
|
||||||
|
// en-CA даёт ровно YYYY-MM-DD
|
||||||
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cell = { date: Date; inCurrentMonth: boolean };
|
||||||
|
|
||||||
|
function buildCalendarGrid(viewYear: number, viewMonth: number): Cell[] {
|
||||||
|
const first = new Date(viewYear, viewMonth, 1);
|
||||||
|
const lead = weekdayMonFirst(first);
|
||||||
|
const total = 42;
|
||||||
|
const cells: Cell[] = [];
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const d = new Date(viewYear, viewMonth, 1 - lead + i);
|
||||||
|
cells.push({ date: d, inCurrentMonth: d.getMonth() === viewMonth });
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHHMMSS(totalSeconds: number) {
|
||||||
|
const s = Math.max(0, Math.floor(totalSeconds));
|
||||||
|
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
|
||||||
|
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
|
||||||
|
const ss = String(s % 60).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcRewardByStreak(streak: number) {
|
||||||
|
return Math.min(10 + Math.max(0, streak - 1) * 10, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClaimed?: (coinsAdded: number) => void;
|
||||||
|
onOpenGame?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DailyStatusCompat = DailyStatusResponse & {
|
||||||
|
was_online_today?: boolean;
|
||||||
|
next_claim_at_utc?: string;
|
||||||
|
next_claim_at_local?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DailyReward({ onClaimed }: Props) {
|
||||||
|
const today = useMemo(() => startOfDay(new Date()), []);
|
||||||
|
const [view, setView] = useState(
|
||||||
|
() => new Date(today.getFullYear(), today.getMonth(), 1),
|
||||||
|
);
|
||||||
|
const [selected, setSelected] = useState<Date>(today);
|
||||||
|
|
||||||
|
// перенесённая логика статуса/клейма
|
||||||
|
const [status, setStatus] = useState<DailyStatusCompat | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [success, setSuccess] = useState<string>('');
|
||||||
|
|
||||||
|
const [claimDays, setClaimDays] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const viewYear = view.getFullYear();
|
||||||
|
const viewMonth = view.getMonth();
|
||||||
|
const grid = useMemo(
|
||||||
|
() => buildCalendarGrid(viewYear, viewMonth),
|
||||||
|
[viewYear, viewMonth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const streak = status?.streak ?? 0;
|
||||||
|
const wasOnlineToday = status?.was_online_today ?? false;
|
||||||
|
const canClaim = (status?.can_claim ?? false) && wasOnlineToday;
|
||||||
|
|
||||||
|
const [notifOpen, setNotifOpen] = useState(false);
|
||||||
|
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||||||
|
const [notifSeverity, setNotifSeverity] = useState<
|
||||||
|
'success' | 'info' | 'warning' | 'error'
|
||||||
|
>('info');
|
||||||
|
|
||||||
|
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
const goPrev = () =>
|
||||||
|
setView((v) => new Date(v.getFullYear(), v.getMonth() - 1, 1));
|
||||||
|
const goNext = () =>
|
||||||
|
setView((v) => new Date(v.getFullYear(), v.getMonth() + 1, 1));
|
||||||
|
const goToday = () => {
|
||||||
|
const t = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
setView(t);
|
||||||
|
setSelected(today);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedKey = keyOf(selected);
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const s = (await fetchDailyStatus()) as DailyStatusCompat;
|
||||||
|
setStatus(s);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка загрузки статуса');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadClaimDays = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetchDailyClaimDays(180);
|
||||||
|
if (r.ok) setClaimDays(new Set(r.days));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка загрузки дней наград:', e);
|
||||||
|
// можно setError(...) если хочешь показывать
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
loadClaimDays();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setTick((x) => x + 1), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clientSecondsLeft = useMemo(() => {
|
||||||
|
if (!status) return 0;
|
||||||
|
if (canClaim) return 0;
|
||||||
|
return Math.max(0, (status.seconds_to_next ?? 0) - tick);
|
||||||
|
}, [status, tick, canClaim]);
|
||||||
|
|
||||||
|
// ✅ фикс прогресса: считаем от clientSecondsLeft, а не от status.seconds_to_next (который не меняется)
|
||||||
|
const progressValue = useMemo(() => {
|
||||||
|
const day = 24 * 3600;
|
||||||
|
const remaining = Math.min(day, Math.max(0, clientSecondsLeft));
|
||||||
|
return ((day - remaining) / day) * 100;
|
||||||
|
}, [clientSecondsLeft]);
|
||||||
|
|
||||||
|
const todaysReward = useMemo(() => {
|
||||||
|
const effectiveStreak = canClaim
|
||||||
|
? Math.max(1, streak === 0 ? 1 : streak)
|
||||||
|
: streak;
|
||||||
|
return calcRewardByStreak(effectiveStreak);
|
||||||
|
}, [streak, canClaim]);
|
||||||
|
|
||||||
|
const subtitle = useMemo(() => {
|
||||||
|
if (!status) return '';
|
||||||
|
if (!wasOnlineToday)
|
||||||
|
return 'Награда откроется после входа на сервер сегодня.';
|
||||||
|
if (canClaim) return 'Можно забрать прямо сейчас 🎁';
|
||||||
|
return `До следующей награды: ${formatHHMMSS(clientSecondsLeft)}`;
|
||||||
|
}, [status, wasOnlineToday, canClaim, clientSecondsLeft]);
|
||||||
|
|
||||||
|
const handleClaim = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
try {
|
||||||
|
const res = await claimDaily();
|
||||||
|
|
||||||
|
if (res.claimed) {
|
||||||
|
const added = res.coins_added ?? 0;
|
||||||
|
setSuccess(`Вы получили ${added} монет!`);
|
||||||
|
onClaimed?.(added);
|
||||||
|
} else {
|
||||||
|
if (res.reason === 'not_online_today') {
|
||||||
|
setError(
|
||||||
|
'Чтобы забрать награду — зайдите на сервер сегодня хотя бы на минуту.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(res.reason || 'Награда недоступна');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadStatus();
|
||||||
|
await loadClaimDays();
|
||||||
|
setTick(0);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка при получении награды');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '85vw',
|
||||||
|
height: '100%',
|
||||||
|
paddingBottom: '5vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '1.2vw',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.18), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.14), transparent 55%), rgba(10,10,20,0.94)',
|
||||||
|
boxShadow: '0 1.2vw 3.8vw rgba(0,0,0,0.55)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: '76vh', // подстрой под свой layout
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 5,
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.18), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.14), transparent 55%), rgba(10,10,20,0.94)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* alerts */}
|
||||||
|
<Box sx={{ px: '2vw', pt: '1.2vh' }}>
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
severity="error"
|
||||||
|
icon={false}
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
borderRadius: '1.1vw',
|
||||||
|
px: '1.4vw',
|
||||||
|
py: '1.1vw',
|
||||||
|
color: 'rgba(255,255,255,0.90)',
|
||||||
|
fontWeight: 800,
|
||||||
|
bgcolor: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.45)',
|
||||||
|
'& .MuiAlert-message': {
|
||||||
|
padding: 0,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
'&:before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 12% 30%, rgba(242,113,33,0.22), transparent 60%), radial-gradient(circle at 85% 0%, rgba(233,64,205,0.14), transparent 55%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
'&:after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '0.35vw',
|
||||||
|
background: 'linear-gradient(180deg, #F27121 0%, #E940CD 65%, #8A2387 100%)',
|
||||||
|
opacity: 0.95,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ position: 'relative', zIndex: 1, color: 'rgba(255,255,255,0.90)', fontWeight: 800 }}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
// <Alert
|
||||||
|
// severity="success"
|
||||||
|
// icon={false}
|
||||||
|
// sx={{
|
||||||
|
// mb: 2,
|
||||||
|
// borderRadius: '1.1vw',
|
||||||
|
// px: '1.4vw',
|
||||||
|
// py: '1.1vw',
|
||||||
|
// color: 'rgba(255,255,255,0.90)',
|
||||||
|
// fontWeight: 800,
|
||||||
|
// bgcolor: 'rgba(255,255,255,0.04)',
|
||||||
|
// border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
// position: 'relative',
|
||||||
|
// overflow: 'hidden',
|
||||||
|
// backdropFilter: 'blur(10px)',
|
||||||
|
// boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.45)',
|
||||||
|
// '& .MuiAlert-message': {
|
||||||
|
// padding: 0,
|
||||||
|
// width: '100%',
|
||||||
|
// },
|
||||||
|
// '&:before': {
|
||||||
|
// content: '""',
|
||||||
|
// position: 'absolute',
|
||||||
|
// inset: 0,
|
||||||
|
// background:
|
||||||
|
// 'radial-gradient(circle at 12% 30%, rgba(242,113,33,0.22), transparent 60%), radial-gradient(circle at 85% 0%, rgba(233,64,205,0.14), transparent 55%)',
|
||||||
|
// pointerEvents: 'none',
|
||||||
|
// },
|
||||||
|
// '&:after': {
|
||||||
|
// content: '""',
|
||||||
|
// position: 'absolute',
|
||||||
|
// left: 0,
|
||||||
|
// top: 0,
|
||||||
|
// bottom: 0,
|
||||||
|
// width: '0.35vw',
|
||||||
|
// background: 'linear-gradient(180deg, #F27121 0%, #E940CD 65%, #8A2387 100%)',
|
||||||
|
// opacity: 0.95,
|
||||||
|
// pointerEvents: 'none',
|
||||||
|
// },
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <Typography sx={{ position: 'relative', zIndex: 1, color: 'rgba(255,255,255,0.90)', fontWeight: 800 }}>
|
||||||
|
// {success}
|
||||||
|
// </Typography>
|
||||||
|
// </Alert>
|
||||||
|
<CustomNotification
|
||||||
|
open={notifOpen}
|
||||||
|
message={notifMsg}
|
||||||
|
severity={notifSeverity}
|
||||||
|
position={notifPos}
|
||||||
|
onClose={() => setNotifOpen(false)}
|
||||||
|
autoHideDuration={99999}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: '2vw',
|
||||||
|
pb: '2vw',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '2vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minWidth: 220,
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1vw',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{ color: 'rgba(255,255,255,0.75)', display: 'flex', gap: '0.7vw' }}
|
||||||
|
>
|
||||||
|
<CoinsDisplay value={todaysReward} size="small" />
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily:
|
||||||
|
'Benzin-Bold, system-ui, -apple-system, Segoe UI, Roboto, Arial',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: '2vw',
|
||||||
|
color: '#fff',
|
||||||
|
lineHeight: 1.15,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Серия дней: <b>{streak}</b>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<CustomTooltip title="К текущему месяцу">
|
||||||
|
<IconButton
|
||||||
|
onClick={goToday}
|
||||||
|
sx={{
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
bgcolor: 'rgba(0,0,0,0.22)',
|
||||||
|
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TodayRoundedIcon sx={{ fontSize: '2vw', p: '0.2vw' }} />
|
||||||
|
</IconButton>
|
||||||
|
</CustomTooltip>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={goPrev}
|
||||||
|
sx={{
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
bgcolor: 'rgba(0,0,0,0.22)',
|
||||||
|
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeftRoundedIcon sx={{ fontSize: '2vw', p: '0.2vw' }} />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Box sx={{ minWidth: 160, textAlign: 'center', maxWidth: '15vw' }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
fontSize: '1.5vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{RU_MONTHS[viewMonth]} {viewYear}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={goNext}
|
||||||
|
sx={{
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
bgcolor: 'rgba(0,0,0,0.22)',
|
||||||
|
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRightRoundedIcon sx={{ fontSize: '2vw', p: '0.2vw' }} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Calendar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: '2vw',
|
||||||
|
py: '2vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1, // занимает всё оставшееся место под шапкой
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
|
gap: '0.7vw',
|
||||||
|
mb: '1.2vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{RU_WEEKDAYS.map((w, i) => (
|
||||||
|
<Typography
|
||||||
|
key={w}
|
||||||
|
sx={{
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 'clamp(10px, 1.1vw, 14px)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color:
|
||||||
|
i >= 5 ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.6)',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
|
gap: '0.7vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{grid.map(({ date, inCurrentMonth }) => {
|
||||||
|
const d = startOfDay(date);
|
||||||
|
const isToday = isSameDay(d, today);
|
||||||
|
const isSelected = isSameDay(d, selected);
|
||||||
|
|
||||||
|
const dayKeyEkb = keyOfInTZ(d, EKATERINBURG_TZ);
|
||||||
|
const claimed = claimDays.has(dayKeyEkb);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonBase
|
||||||
|
key={dayKeyEkb}
|
||||||
|
onClick={() => setSelected(d)}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '1 / 1',
|
||||||
|
borderRadius: '1vw',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: isSelected
|
||||||
|
? '1px solid rgba(242,113,33,0.85)'
|
||||||
|
: 'none',
|
||||||
|
bgcolor: inCurrentMonth
|
||||||
|
? 'rgba(0,0,0,0.24)'
|
||||||
|
: 'rgba(0,0,0,0.12)',
|
||||||
|
transition:
|
||||||
|
'transform 0.18s ease, background-color 0.18s ease, border-color 0.18s ease',
|
||||||
|
transform: isSelected ? 'scale(1.02)' : 'scale(1)',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: inCurrentMonth
|
||||||
|
? 'rgba(255,255,255,0.06)'
|
||||||
|
: 'rgba(255,255,255,0.04)',
|
||||||
|
transform: 'translateY(-1px)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isToday && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
...pulseGradient,
|
||||||
|
position: 'absolute',
|
||||||
|
inset: -20,
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 50% 50%, rgba(233,64,205,0.35), transparent 55%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
animation: 'pulseGlow 2.6s ease-in-out infinite',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.3,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.3vw',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: inCurrentMonth
|
||||||
|
? '#fff'
|
||||||
|
: 'rgba(255,255,255,0.35)',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d.getDate()}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '1vw',
|
||||||
|
color: claimed
|
||||||
|
? 'rgba(156, 255, 198, 0.9)'
|
||||||
|
: isToday
|
||||||
|
? 'rgba(242,113,33,0.95)'
|
||||||
|
: 'rgba(255,255,255,0.45)',
|
||||||
|
fontWeight: 700,
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{claimed ? 'получено' : isToday ? 'сегодня' : ''}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{claimed && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '1vh',
|
||||||
|
width: '0.45vw',
|
||||||
|
height: '0.45vw',
|
||||||
|
borderRadius: '999vw',
|
||||||
|
bgcolor: 'rgba(156, 255, 198, 0.95)',
|
||||||
|
boxShadow: '0 0 1vw rgba(156, 255, 198, 0.35)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</ButtonBase>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Footer actions */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: '2vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1vw',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
sx={{ color: 'rgba(255,255,255,0.65)', fontSize: '1.2vw' }}
|
||||||
|
>
|
||||||
|
Выбрано:{' '}
|
||||||
|
<span style={{ color: '#fff', fontWeight: 800 }}>
|
||||||
|
{selectedKey}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: '1.2vw', alignItems: 'center' }}>
|
||||||
|
<CustomTooltip title={subtitle} disableInteractive>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'inline-block',
|
||||||
|
cursor:
|
||||||
|
loading || !status?.ok || !canClaim ? 'help' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading || !status?.ok || !canClaim}
|
||||||
|
onClick={handleClaim}
|
||||||
|
sx={{
|
||||||
|
px: '2.4vw',
|
||||||
|
py: '1vh',
|
||||||
|
borderRadius: '2vw',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontFamily:
|
||||||
|
'Benzin-Bold, system-ui, -apple-system, Segoe UI, Roboto, Arial',
|
||||||
|
background:
|
||||||
|
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||||
|
transition:
|
||||||
|
'transform 0.25s ease, box-shadow 0.25s ease, filter 0.25s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(0.98)',
|
||||||
|
filter: 'brightness(0.92)',
|
||||||
|
boxShadow: '0 0.5vw 1vw rgba(0, 0, 0, 0.3)',
|
||||||
|
},
|
||||||
|
'&.Mui-disabled': {
|
||||||
|
background: 'rgba(255,255,255,0.10)',
|
||||||
|
color: 'rgba(255,255,255,0.45)',
|
||||||
|
pointerEvents: 'none', // важно оставить
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Забираем...' : 'Забрать'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CustomTooltip>
|
||||||
|
|
||||||
|
<CustomTooltip title="Сбросить выбор на сегодня">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setSelected(today)}
|
||||||
|
sx={{
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
bgcolor: 'rgba(0,0,0,0.22)',
|
||||||
|
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TodayRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</CustomTooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,19 +2,18 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
Button,
|
Button,
|
||||||
Snackbar,
|
|
||||||
Alert,
|
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Modal,
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import ServerStatus from '../components/ServerStatus/ServerStatus';
|
import ServerStatus from '../components/ServerStatus/ServerStatus';
|
||||||
import PopaPopa from '../components/popa-popa';
|
import PopaPopa from '../components/popa-popa';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import MemorySlider from '../components/Login/MemorySlider';
|
import SettingsModal from '../components/Settings/SettingsModal';
|
||||||
import FilesSelector from '../components/FilesSelector';
|
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||||
|
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||||
|
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -30,7 +29,9 @@ declare global {
|
|||||||
|
|
||||||
// Определяем тип для props
|
// Определяем тип для props
|
||||||
interface LaunchPageProps {
|
interface LaunchPageProps {
|
||||||
launchOptions: {
|
onLaunchPage?: () => void;
|
||||||
|
launchOptions?: {
|
||||||
|
// Делаем опциональным
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
apiReleaseUrl: string;
|
apiReleaseUrl: string;
|
||||||
versionFileName: string;
|
versionFileName: string;
|
||||||
@ -42,8 +43,14 @@ interface LaunchPageProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
|
const LaunchPage = ({
|
||||||
|
onLaunchPage,
|
||||||
|
launchOptions = {} as any,
|
||||||
|
}: LaunchPageProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { versionId } = useParams();
|
||||||
|
const [versionConfig, setVersionConfig] = useState<any>(null);
|
||||||
|
|
||||||
// Начальное состояние должно быть пустым или с минимальными значениями
|
// Начальное состояние должно быть пустым или с минимальными значениями
|
||||||
const [config, setConfig] = useState<{
|
const [config, setConfig] = useState<{
|
||||||
memory: number;
|
memory: number;
|
||||||
@ -53,17 +60,23 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
|
|||||||
preserveFiles: [],
|
preserveFiles: [],
|
||||||
});
|
});
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [buffer, setBuffer] = useState(10);
|
const [buffer, setBuffer] = useState(10);
|
||||||
const [installStatus, setInstallStatus] = useState('');
|
const [installStatus, setInstallStatus] = useState('');
|
||||||
const [notification, setNotification] = useState<{
|
const [notifOpen, setNotifOpen] = useState(false);
|
||||||
open: boolean;
|
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||||||
message: string;
|
const [notifSeverity, setNotifSeverity] = useState<
|
||||||
severity: 'success' | 'error' | 'info';
|
'success' | 'info' | 'warning' | 'error'
|
||||||
}>({ open: false, message: '', severity: 'info' });
|
>('info');
|
||||||
|
|
||||||
|
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center',
|
||||||
|
});
|
||||||
const [installStep, setInstallStep] = useState('');
|
const [installStep, setInstallStep] = useState('');
|
||||||
const [installMessage, setInstallMessage] = useState('');
|
const [installMessage, setInstallMessage] = useState('');
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [isGameRunning, setIsGameRunning] = useState(false);
|
||||||
const handleOpen = () => setOpen(true);
|
const handleOpen = () => setOpen(true);
|
||||||
const handleClose = () => setOpen(false);
|
const handleClose = () => setOpen(false);
|
||||||
|
|
||||||
@ -73,10 +86,10 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressListener = (...args: unknown[]) => {
|
const overallProgressListener = (...args: unknown[]) => {
|
||||||
const progress = args[0] as number;
|
const value = args[0] as number; // 0..100
|
||||||
setDownloadProgress(progress);
|
setProgress(value);
|
||||||
setBuffer(Math.min(progress + 10, 100));
|
setBuffer(Math.min(value + 10, 100));
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusListener = (...args: unknown[]) => {
|
const statusListener = (...args: unknown[]) => {
|
||||||
@ -85,143 +98,227 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
|
|||||||
setInstallMessage(status.message);
|
setInstallMessage(status.message);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.electron.ipcRenderer.on('download-progress', progressListener);
|
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('installation-status', statusListener);
|
||||||
|
window.electron.ipcRenderer.on(
|
||||||
|
'minecraft-started',
|
||||||
|
minecraftStartedListener,
|
||||||
|
);
|
||||||
|
window.electron.ipcRenderer.on(
|
||||||
|
'minecraft-stopped',
|
||||||
|
minecraftStoppedListener,
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Удаляем только конкретных слушателей, а не всех
|
// Удаляем только конкретных слушателей, а не всех
|
||||||
// Это безопаснее, чем removeAllListeners
|
// Это безопаснее, чем removeAllListeners
|
||||||
const cleanup = window.electron.ipcRenderer.on;
|
const cleanup = window.electron.ipcRenderer.on;
|
||||||
if (typeof cleanup === 'function') {
|
if (typeof cleanup === 'function') {
|
||||||
cleanup('download-progress', progressListener);
|
|
||||||
cleanup('installation-status', statusListener);
|
cleanup('installation-status', statusListener);
|
||||||
|
cleanup('minecraft-error', statusListener);
|
||||||
|
cleanup('overall-progress', overallProgressListener);
|
||||||
}
|
}
|
||||||
// Удаляем использование removeAllListeners
|
// Удаляем использование removeAllListeners
|
||||||
};
|
};
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Загрузка конфигурации сборки при монтировании
|
const fetchVersionConfig = async () => {
|
||||||
const loadPackConfig = async () => {
|
if (!versionId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Сначала проверяем, есть ли конфигурация в localStorage
|
||||||
|
const savedConfig = localStorage.getItem('selected_version_config');
|
||||||
|
if (savedConfig) {
|
||||||
|
const parsedConfig = JSON.parse(savedConfig);
|
||||||
|
|
||||||
|
// Если конфиг пустой — считаем, что он невалидный и идём по 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(
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
'load-pack-config',
|
'get-version-config',
|
||||||
{
|
{ versionId },
|
||||||
packName: launchOptions.packName,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success && result.config) {
|
if (result.success) {
|
||||||
// Полностью заменяем config значениями из файла
|
setVersionConfig(result.config);
|
||||||
setConfig(result.config);
|
setConfig({
|
||||||
|
memory: result.config.memory || 4096,
|
||||||
|
preserveFiles: result.config.preserveFiles || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Если не удалось получить конфигурацию, используем значения по умолчанию
|
||||||
|
const defaultConfig = {
|
||||||
|
downloadUrl: '',
|
||||||
|
apiReleaseUrl: '',
|
||||||
|
versionFileName: `${versionId}_version.txt`,
|
||||||
|
packName: versionId || 'Comfort',
|
||||||
|
memory: 4096,
|
||||||
|
baseVersion: '1.21.4',
|
||||||
|
serverIp: 'popa-popa.ru',
|
||||||
|
fabricVersion: '0.16.14',
|
||||||
|
preserveFiles: ['popa-launcher-config.json'],
|
||||||
|
};
|
||||||
|
setVersionConfig(defaultConfig);
|
||||||
|
setConfig({
|
||||||
|
memory: defaultConfig.memory,
|
||||||
|
preserveFiles: defaultConfig.preserveFiles || [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при загрузке настроек:', error);
|
console.error('Ошибка при получении настроек версии:', error);
|
||||||
|
// Используем значения по умолчанию
|
||||||
|
const defaultConfig = {
|
||||||
|
downloadUrl: '',
|
||||||
|
apiReleaseUrl: '',
|
||||||
|
versionFileName: `${versionId}_version.txt`,
|
||||||
|
packName: versionId || 'Comfort',
|
||||||
|
memory: 4096,
|
||||||
|
baseVersion: '1.21.4',
|
||||||
|
serverIp: 'popa-popa.ru',
|
||||||
|
fabricVersion: '0.16.14',
|
||||||
|
preserveFiles: ['popa-launcher-config.json'],
|
||||||
|
};
|
||||||
|
setVersionConfig(defaultConfig);
|
||||||
|
setConfig({
|
||||||
|
memory: defaultConfig.memory,
|
||||||
|
preserveFiles: defaultConfig.preserveFiles || [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadPackConfig();
|
fetchVersionConfig();
|
||||||
}, [launchOptions.packName]);
|
}, [versionId]);
|
||||||
|
|
||||||
const showNotification = (
|
const showNotification = (
|
||||||
message: string,
|
message: React.ReactNode,
|
||||||
severity: 'success' | 'error' | 'info',
|
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
|
||||||
|
position: NotificationPosition = getNotifPositionFromSettings(),
|
||||||
) => {
|
) => {
|
||||||
setNotification({ open: true, message, severity });
|
if (!isNotificationsEnabled()) return;
|
||||||
};
|
setNotifMsg(message);
|
||||||
|
setNotifSeverity(severity);
|
||||||
const handleCloseNotification = () => {
|
setNotifPos(position);
|
||||||
setNotification({ ...notification, open: false });
|
setNotifOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Функция для запуска игры с настройками выбранной версии
|
||||||
const handleLaunchMinecraft = async () => {
|
const handleLaunchMinecraft = async () => {
|
||||||
try {
|
try {
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setDownloadProgress(0);
|
|
||||||
setBuffer(10);
|
setBuffer(10);
|
||||||
|
|
||||||
// Загружаем настройки сборки
|
// Используем настройки выбранной версии или дефолтные
|
||||||
const result = await window.electron.ipcRenderer.invoke(
|
const currentConfig = versionConfig || {
|
||||||
'load-pack-config',
|
packName: versionId || 'Comfort',
|
||||||
{
|
memory: 4096,
|
||||||
packName: launchOptions.packName,
|
baseVersion: '1.21.4',
|
||||||
},
|
serverIp: 'popa-popa.ru',
|
||||||
);
|
fabricVersion: '0.16.14',
|
||||||
|
preserveFiles: [],
|
||||||
|
};
|
||||||
|
|
||||||
// Используйте уже существующий state вместо локальной переменной
|
// Проверяем, является ли это ванильной версией
|
||||||
if (result.success && result.config) {
|
const isVanillaVersion =
|
||||||
setConfig(result.config); // Обновляем state
|
!currentConfig.downloadUrl || currentConfig.downloadUrl === '';
|
||||||
|
|
||||||
|
if (!isVanillaVersion) {
|
||||||
|
// Если это не ванильная версия, выполняем загрузку и распаковку
|
||||||
|
const packOptions = {
|
||||||
|
downloadUrl: currentConfig.downloadUrl,
|
||||||
|
apiReleaseUrl: currentConfig.apiReleaseUrl,
|
||||||
|
versionFileName: currentConfig.versionFileName,
|
||||||
|
packName: versionId || currentConfig.packName,
|
||||||
|
preserveFiles: config.preserveFiles,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Передаем опции для скачивания
|
||||||
|
const downloadResult = await window.electron.ipcRenderer.invoke(
|
||||||
|
'download-and-extract',
|
||||||
|
packOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (downloadResult?.success) {
|
||||||
|
if (downloadResult.updated) {
|
||||||
|
showNotification(
|
||||||
|
`Сборка ${downloadResult.packName} успешно обновлена до версии ${downloadResult.version}`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(
|
||||||
|
`Установлена актуальная версия сборки ${downloadResult.packName} (${downloadResult.version})`,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showNotification('Запускаем ванильный Minecraft...', 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Опции для запуска Minecraft
|
||||||
const savedConfig = JSON.parse(
|
const savedConfig = JSON.parse(
|
||||||
localStorage.getItem('launcher_config') || '{}',
|
localStorage.getItem('launcher_config') || '{}',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Опции для скачивания сборки
|
const options = {
|
||||||
const packOptions = {
|
accessToken: savedConfig.accessToken,
|
||||||
downloadUrl: launchOptions.downloadUrl,
|
uuid: savedConfig.uuid,
|
||||||
apiReleaseUrl: launchOptions.apiReleaseUrl,
|
username: savedConfig.username,
|
||||||
versionFileName: launchOptions.versionFileName,
|
memory: config.memory,
|
||||||
packName: launchOptions.packName,
|
baseVersion: currentConfig.baseVersion,
|
||||||
preserveFiles: config.preserveFiles,
|
packName: versionId || currentConfig.packName,
|
||||||
|
serverIp: currentConfig.serverIp,
|
||||||
|
fabricVersion: currentConfig.fabricVersion,
|
||||||
|
// Для ванильной версии устанавливаем флаг
|
||||||
|
isVanillaVersion: isVanillaVersion,
|
||||||
|
versionToLaunchOverride: isVanillaVersion ? versionId : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Передаем опции для скачивания
|
const launchResult = await window.electron.ipcRenderer.invoke(
|
||||||
const downloadResult = await window.electron.ipcRenderer.invoke(
|
'launch-minecraft',
|
||||||
'download-and-extract',
|
options,
|
||||||
packOptions,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (downloadResult?.success) {
|
if (launchResult?.success) {
|
||||||
let needsSecondAttempt = false;
|
showNotification('Minecraft успешно запущен!', 'success');
|
||||||
|
|
||||||
if (downloadResult.updated) {
|
|
||||||
showNotification(
|
|
||||||
`Сборка ${downloadResult.packName} успешно обновлена до версии ${downloadResult.version}`,
|
|
||||||
'success',
|
|
||||||
);
|
|
||||||
needsSecondAttempt = true;
|
|
||||||
} else {
|
|
||||||
showNotification(
|
|
||||||
`Установлена актуальная версия сборки ${downloadResult.packName} (${downloadResult.version})`,
|
|
||||||
'info',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Опции для запуска
|
|
||||||
const options = {
|
|
||||||
accessToken: savedConfig.accessToken,
|
|
||||||
uuid: savedConfig.uuid,
|
|
||||||
username: savedConfig.username,
|
|
||||||
memory: config.memory, // Используем state
|
|
||||||
baseVersion: launchOptions.baseVersion,
|
|
||||||
packName: launchOptions.packName,
|
|
||||||
serverIp: launchOptions.serverIp,
|
|
||||||
fabricVersion: launchOptions.fabricVersion,
|
|
||||||
};
|
|
||||||
|
|
||||||
const launchResult = await window.electron.ipcRenderer.invoke(
|
|
||||||
'launch-minecraft',
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (needsSecondAttempt) {
|
|
||||||
showNotification(
|
|
||||||
'Завершаем настройку компонентов, повторный запуск...',
|
|
||||||
'info',
|
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
||||||
|
|
||||||
const secondAttempt = await window.electron.ipcRenderer.invoke(
|
|
||||||
'launch-minecraft',
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
showNotification('Minecraft успешно запущен!', 'success');
|
|
||||||
} else if (launchResult?.success) {
|
|
||||||
showNotification('Minecraft успешно запущен!', 'success');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
@ -231,6 +328,28 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 () => {
|
const savePackConfig = async () => {
|
||||||
try {
|
try {
|
||||||
@ -240,7 +359,7 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await window.electron.ipcRenderer.invoke('save-pack-config', {
|
await window.electron.ipcRenderer.invoke('save-pack-config', {
|
||||||
packName: launchOptions.packName,
|
packName: versionId || versionConfig?.packName || 'Comfort',
|
||||||
config: configToSave,
|
config: configToSave,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -267,7 +386,7 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
|
|||||||
СЕРВЕР ГДЕ ВСЕМ НА ВАС ПОХУЙ
|
СЕРВЕР ГДЕ ВСЕМ НА ВАС ПОХУЙ
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
|
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
|
||||||
СЕРВЕР ГДЕ РАЗРЕШИНЫ ОДНОПОЛЫЕ БРАКИ
|
СЕРВЕР ГДЕ РАЗРЕШЕНЫ ОДНОПОЛЫЕ БРАКИ
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
|
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
|
||||||
СЕРВЕР ГДЕ ВСЕ ДОЛБАЕБЫ
|
СЕРВЕР ГДЕ ВСЕ ДОЛБАЕБЫ
|
||||||
@ -285,7 +404,7 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<ServerStatus
|
<ServerStatus
|
||||||
serverIp={launchOptions.serverIp}
|
serverIp={versionConfig?.serverIp || 'popa-popa.ru'}
|
||||||
refreshInterval={30000}
|
refreshInterval={30000}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@ -296,15 +415,40 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
|
|||||||
<Box sx={{ width: '100%', mr: 1 }}>
|
<Box sx={{ width: '100%', mr: 1 }}>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="buffer"
|
variant="buffer"
|
||||||
value={downloadProgress}
|
value={progress}
|
||||||
valueBuffer={buffer}
|
valueBuffer={buffer}
|
||||||
|
sx={{
|
||||||
|
height: '0.45vw',
|
||||||
|
borderRadius: '1vw',
|
||||||
|
|
||||||
|
// Фон прогресс-бара (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)',
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
|
||||||
|
'& .MuiLinearProgress-dashed': {
|
||||||
|
// Линии пунктирного эффекта
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ minWidth: 35 }}>
|
<Box sx={{ minWidth: 35 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{ color: 'white' }}
|
sx={{ color: 'white' }}
|
||||||
>{`${Math.round(downloadProgress)}%`}</Typography>
|
>{`${Math.round(progress)}%`}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@ -320,16 +464,43 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleLaunchMinecraft}
|
onClick={
|
||||||
|
isGameRunning ? handleStopMinecraft : handleLaunchMinecraft
|
||||||
|
}
|
||||||
sx={{
|
sx={{
|
||||||
flexGrow: 1, // занимает всё свободное место
|
flexGrow: 1,
|
||||||
width: 'auto', // ширина подстраивается
|
width: 'auto',
|
||||||
borderRadius: '3vw',
|
borderRadius: '3vw',
|
||||||
fontFamily: 'Benzin-Bold',
|
fontFamily: 'Benzin-Bold',
|
||||||
background: 'linear-gradient(90deg, #3B96FF 0%, #FFB7ED 100%)',
|
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.01)',
|
||||||
|
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.01)',
|
||||||
|
boxShadow: '0 4px 15px rgba(242, 113, 33, 0.4)',
|
||||||
|
},
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
||||||
|
transition: 'all 0.25s ease',
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Запустить Minecraft
|
{isGameRunning ? 'Остановить Minecraft' : 'Запустить Minecraft'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Вторая кнопка — квадратная, фиксированного размера (ширина = высоте) */}
|
{/* Вторая кнопка — квадратная, фиксированного размера (ширина = высоте) */}
|
||||||
@ -343,6 +514,13 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
|
|||||||
minHeight: 'unset',
|
minHeight: 'unset',
|
||||||
minWidth: 'unset',
|
minWidth: 'unset',
|
||||||
height: '100%', // занимает полную высоту родителя
|
height: '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)',
|
||||||
|
},
|
||||||
|
transition: 'transform 0.25s ease, box-shadow 0.25s ease',
|
||||||
}}
|
}}
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
>
|
>
|
||||||
@ -351,79 +529,23 @@ const LaunchPage = ({ launchOptions }: LaunchPageProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Snackbar
|
<CustomNotification
|
||||||
open={notification.open}
|
open={notifOpen}
|
||||||
autoHideDuration={6000}
|
message={notifMsg}
|
||||||
onClose={handleCloseNotification}
|
severity={notifSeverity}
|
||||||
>
|
position={notifPos}
|
||||||
<Alert
|
onClose={() => setNotifOpen(false)}
|
||||||
onClose={handleCloseNotification}
|
autoHideDuration={2500}
|
||||||
severity={notification.severity}
|
/>
|
||||||
sx={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
{notification.message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
|
|
||||||
<Modal
|
<SettingsModal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
aria-labelledby="modal-modal-title"
|
config={config}
|
||||||
aria-describedby="modal-modal-description"
|
onConfigChange={setConfig}
|
||||||
>
|
packName={versionId || versionConfig?.packName || 'Comfort'}
|
||||||
<Box
|
onSave={savePackConfig}
|
||||||
sx={{
|
/>
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
width: 400,
|
|
||||||
background:
|
|
||||||
'linear-gradient(-242.94deg, #000000 39.07%, #3b4187 184.73%)',
|
|
||||||
border: '2px solid #000',
|
|
||||||
boxShadow: 24,
|
|
||||||
p: 4,
|
|
||||||
borderRadius: '3vw',
|
|
||||||
gap: '1vh',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography id="modal-modal-title" variant="body1" component="h2">
|
|
||||||
Файлы и папки, которые будут сохранены после переустановки сборки
|
|
||||||
</Typography>
|
|
||||||
<FilesSelector
|
|
||||||
packName={launchOptions.packName}
|
|
||||||
initialSelected={config.preserveFiles} // Передаем текущие выбранные файлы
|
|
||||||
onSelectionChange={(selected) => {
|
|
||||||
setConfig((prev) => ({ ...prev, preserveFiles: selected }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography variant="body1" sx={{ color: 'white' }}>
|
|
||||||
Оперативная память выделенная для Minecraft
|
|
||||||
</Typography>
|
|
||||||
<MemorySlider
|
|
||||||
memory={config.memory}
|
|
||||||
onChange={(e, value) => {
|
|
||||||
setConfig((prev) => ({ ...prev, memory: value as number }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="success"
|
|
||||||
onClick={() => {
|
|
||||||
savePackConfig();
|
|
||||||
handleClose();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
borderRadius: '3vw',
|
|
||||||
fontFamily: 'Benzin-Bold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Modal>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,12 +5,19 @@ import MemorySlider from '../components/Login/MemorySlider';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import PopaPopa from '../components/popa-popa';
|
import PopaPopa from '../components/popa-popa';
|
||||||
import useConfig from '../hooks/useConfig';
|
import useConfig from '../hooks/useConfig';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||||
|
|
||||||
const Login = () => {
|
interface LoginProps {
|
||||||
|
onLoginSuccess?: (username: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Login = ({ onLoginSuccess }: LoginProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { config, setConfig, saveConfig, handleInputChange } = useConfig();
|
const { config, setConfig, saveConfig, handleInputChange } = useConfig();
|
||||||
const { status, validateSession, refreshSession, authenticateWithElyBy } =
|
const { status, validateSession, refreshSession, authenticateWithElyBy } =
|
||||||
useAuth();
|
useAuth();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const authorization = async () => {
|
const authorization = async () => {
|
||||||
console.log('Начинаем процесс авторизации...');
|
console.log('Начинаем процесс авторизации...');
|
||||||
@ -21,6 +28,7 @@ const Login = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Проверяем, есть ли сохранённый токен
|
// Проверяем, есть ли сохранённый токен
|
||||||
if (config.accessToken && config.clientToken) {
|
if (config.accessToken && config.clientToken) {
|
||||||
@ -38,13 +46,25 @@ const Login = () => {
|
|||||||
console.log(
|
console.log(
|
||||||
'Не удалось обновить токен, требуется новая авторизация',
|
'Не удалось обновить токен, требуется новая авторизация',
|
||||||
);
|
);
|
||||||
const newSession = await authenticateWithElyBy(
|
// Очищаем недействительные токены
|
||||||
config.username,
|
saveConfig({
|
||||||
config.password,
|
accessToken: '',
|
||||||
saveConfig,
|
clientToken: '',
|
||||||
);
|
});
|
||||||
if (!newSession) {
|
|
||||||
console.log('Авторизация не удалась');
|
// Пытаемся выполнить новую авторизацию
|
||||||
|
if (config.password) {
|
||||||
|
const newSession = await authenticateWithElyBy(
|
||||||
|
config.username,
|
||||||
|
config.password,
|
||||||
|
saveConfig,
|
||||||
|
);
|
||||||
|
if (!newSession) {
|
||||||
|
console.log('Авторизация не удалась');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Требуется ввод пароля для новой авторизации');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -53,6 +73,13 @@ const Login = () => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('Токен отсутствует, выполняем авторизацию...');
|
console.log('Токен отсутствует, выполняем авторизацию...');
|
||||||
|
// Проверяем наличие пароля
|
||||||
|
if (!config.password) {
|
||||||
|
console.log('Ошибка: не указан пароль');
|
||||||
|
alert('Введите пароль!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const session = await authenticateWithElyBy(
|
const session = await authenticateWithElyBy(
|
||||||
config.username,
|
config.username,
|
||||||
config.password,
|
config.password,
|
||||||
@ -65,26 +92,37 @@ const Login = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Авторизация успешно завершена');
|
console.log('Авторизация успешно завершена');
|
||||||
|
|
||||||
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess(config.username);
|
||||||
|
}
|
||||||
|
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.log(`ОШИБКА при авторизации: ${error.message}`);
|
console.log(`ОШИБКА при авторизации: ${error.message}`);
|
||||||
|
saveConfig({
|
||||||
|
accessToken: '',
|
||||||
|
clientToken: '',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<PopaPopa />
|
{loading ? (
|
||||||
<AuthForm
|
<FullScreenLoader message="Входим..." />
|
||||||
config={config}
|
) : (
|
||||||
handleInputChange={handleInputChange}
|
<>
|
||||||
onLogin={authorization}
|
<PopaPopa />
|
||||||
/>
|
<AuthForm
|
||||||
<MemorySlider
|
config={config}
|
||||||
memory={config.memory}
|
handleInputChange={handleInputChange}
|
||||||
onChange={(e, value) => {
|
onLogin={authorization}
|
||||||
setConfig((prev) => ({ ...prev, memory: value as number }));
|
/>
|
||||||
}}
|
</>
|
||||||
/>
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
500
src/renderer/pages/Marketplace.tsx
Normal file
500
src/renderer/pages/Marketplace.tsx
Normal file
@ -0,0 +1,500 @@
|
|||||||
|
// src/renderer/pages/Marketplace.tsx
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
Pagination,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
} 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';
|
||||||
|
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||||
|
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { playBuySound } from '../utils/sounds';
|
||||||
|
import { translateServer } from '../utils/serverTranslator'
|
||||||
|
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
|
||||||
|
|
||||||
|
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 [notifOpen, setNotifOpen] = useState(false);
|
||||||
|
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||||||
|
const [notifSeverity, setNotifSeverity] = useState<
|
||||||
|
'success' | 'info' | 'warning' | 'error'
|
||||||
|
>('info');
|
||||||
|
|
||||||
|
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Функция для проверки онлайн-статуса игрока и определения сервера
|
||||||
|
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);
|
||||||
|
|
||||||
|
playBuySound();
|
||||||
|
|
||||||
|
showNotification(
|
||||||
|
result.message ||
|
||||||
|
'Предмет успешно куплен! Он будет добавлен в ваш инвентарь.',
|
||||||
|
'success',
|
||||||
|
{ vertical: 'bottom', horizontal: 'left' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обновляем список предметов
|
||||||
|
if (playerServer) {
|
||||||
|
loadMarketItems(playerServer.ip, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при покупке предмета:', error);
|
||||||
|
showNotification(
|
||||||
|
error instanceof Error ? error.message : 'Ошибка при покупке предмета',
|
||||||
|
'error',
|
||||||
|
{ vertical: 'bottom', horizontal: 'left' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNotification = (
|
||||||
|
message: React.ReactNode,
|
||||||
|
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
|
||||||
|
position: NotificationPosition = getNotifPositionFromSettings(),
|
||||||
|
) => {
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg(message);
|
||||||
|
setNotifSeverity(severity);
|
||||||
|
setNotifPos(position);
|
||||||
|
setNotifOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Получаем имя пользователя из 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CustomNotification
|
||||||
|
open={notifOpen}
|
||||||
|
message={notifMsg}
|
||||||
|
severity={notifSeverity}
|
||||||
|
position={notifPos}
|
||||||
|
onClose={() => setNotifOpen(false)}
|
||||||
|
autoHideDuration={2500}
|
||||||
|
/>
|
||||||
|
<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: '0 2vw 2vw 2vw', width: '95%', height: '100%', mt: '10vh' }}>
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем уведомление
|
||||||
|
showNotification('Предмет успешно выставлен на продажу!', 'success', {
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'left',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
color="white"
|
||||||
|
sx={{ textAlign: 'center', my: 4 }}
|
||||||
|
>
|
||||||
|
Не удалось загрузить инвентарь.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
<CustomNotification
|
||||||
|
open={notifOpen}
|
||||||
|
message={notifMsg}
|
||||||
|
severity={notifSeverity}
|
||||||
|
position={notifPos}
|
||||||
|
onClose={() => setNotifOpen(false)}
|
||||||
|
autoHideDuration={2500}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
697
src/renderer/pages/News.tsx
Normal file
697
src/renderer/pages/News.tsx
Normal file
@ -0,0 +1,697 @@
|
|||||||
|
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={{
|
||||||
|
px: '7vw',
|
||||||
|
pb: '4vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '2vh',
|
||||||
|
width: '85%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Админский редактор */}
|
||||||
|
{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',
|
||||||
|
borderRadius: '1.2vw'
|
||||||
|
},
|
||||||
|
'& .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',
|
||||||
|
borderRadius: '1.2vw',
|
||||||
|
},
|
||||||
|
'& .MuiInputLabel-root': {
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
'& .EasyMDEContainer': {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
borderRadius: '1.2vw',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: 'none'
|
||||||
|
},
|
||||||
|
'& .editor-toolbar': { // полоски(разделители) иконок
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: '1px solid #FFFFFF'
|
||||||
|
},
|
||||||
|
'& .editor-toolbar .fa': { // все иконки
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
'& .CodeMirror': { // поле ввода
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none'
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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: '1vw',
|
||||||
|
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)',
|
||||||
|
transform: 'scale(1.02)',
|
||||||
|
},
|
||||||
|
transition: 'all 0.5s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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)',
|
||||||
|
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
// 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)',
|
||||||
|
},
|
||||||
|
transition: 'all 0.25s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Шапка новости */}
|
||||||
|
<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
|
||||||
|
disableRipple
|
||||||
|
disableFocusRipple
|
||||||
|
disableTouchRipple
|
||||||
|
onClick={() => handleToggleExpand(item.id)}
|
||||||
|
sx={{
|
||||||
|
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
|
background:
|
||||||
|
'rgba(242,113,33,0.15)',
|
||||||
|
borderRadius: '1.4vw',
|
||||||
|
'&:hover': {
|
||||||
|
background:
|
||||||
|
'rgba(242,113,33,0.4)',
|
||||||
|
},
|
||||||
|
transition: 'all 0.25s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExpandMoreIcon
|
||||||
|
sx={{ color: 'rgba(255,255,255,0.9)', fontSize: '1.4vw' }}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
{isAdmin && (
|
||||||
|
<IconButton
|
||||||
|
disableRipple
|
||||||
|
disableFocusRipple
|
||||||
|
disableTouchRipple
|
||||||
|
onClick={() => handleDeleteNews(item.id)}
|
||||||
|
sx={{
|
||||||
|
background: 'rgba(255, 77, 77, 0.1)',
|
||||||
|
borderRadius: '1.4vw',
|
||||||
|
'&:hover': {
|
||||||
|
background: 'rgba(255, 77, 77, 0.3)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon
|
||||||
|
sx={{
|
||||||
|
color: 'rgba(255, 120, 120, 0.9)',
|
||||||
|
fontSize: '1.4vw',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
620
src/renderer/pages/Profile.tsx
Normal file
620
src/renderer/pages/Profile.tsx
Normal file
@ -0,0 +1,620 @@
|
|||||||
|
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';
|
||||||
|
import DailyRewards from '../components/Profile/DailyRewards';
|
||||||
|
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||||
|
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
|
||||||
|
|
||||||
|
export default function Profile() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// notification
|
||||||
|
const [notifOpen, setNotifOpen] = useState(false);
|
||||||
|
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||||||
|
const [notifSeverity, setNotifSeverity] = useState<
|
||||||
|
'success' | 'info' | 'warning' | 'error'
|
||||||
|
>('success');
|
||||||
|
|
||||||
|
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [autoRotate, setAutoRotate] = useState(true);
|
||||||
|
const [walkingSpeed, setWalkingSpeed] = useState(0.5);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const msg = 'Необходимо выбрать файл и указать имя пользователя';
|
||||||
|
setStatusMessage(msg);
|
||||||
|
setUploadStatus('error');
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
// notification
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg(msg);
|
||||||
|
setNotifSeverity('error');
|
||||||
|
setNotifOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadStatus('loading');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadSkin(username, skinFile, skinModel);
|
||||||
|
|
||||||
|
setStatusMessage('Скин успешно загружен!');
|
||||||
|
setUploadStatus('success');
|
||||||
|
|
||||||
|
// 1) подтягиваем свежий skin_url с бэка
|
||||||
|
const config = JSON.parse(localStorage.getItem('launcher_config') || '{}');
|
||||||
|
if (config.uuid) {
|
||||||
|
await loadPlayerData(config.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) сообщаем TopBar'у, что скин обновился
|
||||||
|
window.dispatchEvent(new CustomEvent('skin-updated'));
|
||||||
|
|
||||||
|
// notification
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Скин успешно загружен!');
|
||||||
|
setNotifSeverity('success');
|
||||||
|
setNotifPos(getNotifPositionFromSettings());
|
||||||
|
setNotifOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = `Ошибка: ${
|
||||||
|
error instanceof Error ? error.message : 'Не удалось загрузить скин'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
setStatusMessage(msg);
|
||||||
|
setUploadStatus('error');
|
||||||
|
|
||||||
|
// notification
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg(msg);
|
||||||
|
setNotifSeverity('error');
|
||||||
|
setNotifPos(getNotifPositionFromSettings());
|
||||||
|
setNotifOpen(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const GRADIENT =
|
||||||
|
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const STORAGE_KEY = 'launcher_settings';
|
||||||
|
|
||||||
|
const read = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
const s = raw ? JSON.parse(raw) : null;
|
||||||
|
setAutoRotate(s?.autoRotateSkinViewer ?? true);
|
||||||
|
setWalkingSpeed(s?.walkingSpeed ?? 0.5);
|
||||||
|
} catch {
|
||||||
|
setAutoRotate(true);
|
||||||
|
setWalkingSpeed(0.5);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
read();
|
||||||
|
|
||||||
|
// если хочешь, чтобы обновлялось сразу, когда Settings сохраняют:
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === STORAGE_KEY) read();
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
|
||||||
|
// и наш “локальный” евент (для Electron/одного окна storage может не стрелять)
|
||||||
|
const onSettingsUpdated = () => read();
|
||||||
|
window.addEventListener('settings-updated', onSettingsUpdated as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', onStorage);
|
||||||
|
window.removeEventListener('settings-updated', onSettingsUpdated as EventListener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: '10vh',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflowY: 'auto',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
px: '2vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CustomNotification
|
||||||
|
open={notifOpen}
|
||||||
|
message={notifMsg}
|
||||||
|
severity={notifSeverity}
|
||||||
|
position={notifPos}
|
||||||
|
onClose={() => setNotifOpen(false)}
|
||||||
|
autoHideDuration={2500}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<FullScreenLoader message="Загрузка вашего профиля" />
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
|
||||||
|
gap: '3vw',
|
||||||
|
alignItems: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* LEFT COLUMN */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1vw',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Плашка с ником */}
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
alignSelf: 'center',
|
||||||
|
justifySelf: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '3vw',
|
||||||
|
position: 'relative',
|
||||||
|
px: '5vw',
|
||||||
|
py: '0.9vw',
|
||||||
|
borderRadius: '3vw',
|
||||||
|
color: 'rgba(255,255,255,0.95)',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.20), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.16), transparent 55%), rgba(10,10,20,0.92)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
boxShadow: '0 1.4vw 3.8vw rgba(0,0,0,0.55)',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
'&:after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
left: '8%',
|
||||||
|
right: '8%',
|
||||||
|
bottom: 0,
|
||||||
|
height: '0.35vw',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: GRADIENT,
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* SkinViewer */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SkinViewer
|
||||||
|
width={400}
|
||||||
|
height={465}
|
||||||
|
skinUrl={skin}
|
||||||
|
capeUrl={cape}
|
||||||
|
walkingSpeed={walkingSpeed}
|
||||||
|
autoRotate={autoRotate}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Загрузчик скинов */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
p: '2.2vw',
|
||||||
|
borderRadius: '1.2vw',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* dropzone */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: '1.1vw',
|
||||||
|
p: '1.6vw',
|
||||||
|
mb: '1.1vw',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
transition:
|
||||||
|
'transform 0.18s ease, border-color 0.18s ease, background 0.18s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.005)',
|
||||||
|
borderColor: 'rgba(242,113,33,0.35)',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
},
|
||||||
|
...(isDragOver
|
||||||
|
? {
|
||||||
|
borderColor: 'rgba(233,64,205,0.55)',
|
||||||
|
background:
|
||||||
|
'linear-gradient(120deg, rgba(242,113,33,0.10), rgba(233,64,205,0.08), rgba(138,35,135,0.10))',
|
||||||
|
}
|
||||||
|
: null),
|
||||||
|
'&:after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '0.35vw',
|
||||||
|
background: GRADIENT,
|
||||||
|
opacity: 0.9,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
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: 'rgba(255,255,255,0.92)',
|
||||||
|
fontWeight: 800,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skinFile
|
||||||
|
? `Выбран файл: ${skinFile.name}`
|
||||||
|
: 'Перетащите PNG файл скина или кликните для выбора'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mt: 0.6,
|
||||||
|
color: 'rgba(255,255,255,0.60)',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.9vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Только .png • Рекомендуется 64×64
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* select */}
|
||||||
|
<FormControl
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
mb: '1.1vw',
|
||||||
|
'& .MuiInputLabel-root': {
|
||||||
|
color: 'rgba(255,255,255,0.75)',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
},
|
||||||
|
'& .MuiInputLabel-root.Mui-focused': {
|
||||||
|
color: 'rgba(242,113,33,0.95)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InputLabel>Модель скина</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={skinModel}
|
||||||
|
label="Модель скина"
|
||||||
|
onChange={(e) => setSkinModel(e.target.value)}
|
||||||
|
MenuProps={{
|
||||||
|
PaperProps: {
|
||||||
|
sx: {
|
||||||
|
bgcolor: 'rgba(10,10,20,0.96)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
borderRadius: '1vw',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
'& .MuiMenuItem-root': {
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
},
|
||||||
|
'& .MuiMenuItem-root.Mui-selected': {
|
||||||
|
backgroundColor: 'rgba(242,113,33,0.16)',
|
||||||
|
},
|
||||||
|
'& .MuiMenuItem-root:hover': {
|
||||||
|
backgroundColor: 'rgba(233,64,205,0.14)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '999px',
|
||||||
|
bgcolor: 'rgba(255,255,255,0.04)',
|
||||||
|
color: 'rgba(255,255,255,0.92)',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
py: '0.9vw',
|
||||||
|
px: '1.2vw',
|
||||||
|
},
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'rgba(255,255,255,0.14)',
|
||||||
|
},
|
||||||
|
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'rgba(242,113,33,0.55)',
|
||||||
|
},
|
||||||
|
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'rgba(233,64,205,0.65)',
|
||||||
|
borderWidth: '2px',
|
||||||
|
},
|
||||||
|
'& .MuiSelect-icon': {
|
||||||
|
color: 'rgba(255,255,255,0.75)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">По умолчанию</MenuItem>
|
||||||
|
<MenuItem value="slim">Тонкая (Alex)</MenuItem>
|
||||||
|
<MenuItem value="classic">Классическая (Steve)</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* button */}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
onClick={handleUploadSkin}
|
||||||
|
disabled={uploadStatus === 'loading' || !skinFile}
|
||||||
|
disableRipple
|
||||||
|
sx={{
|
||||||
|
borderRadius: '2.5vw',
|
||||||
|
py: '0.95vw',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
color: '#fff',
|
||||||
|
background: GRADIENT,
|
||||||
|
boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.45)',
|
||||||
|
transition:
|
||||||
|
'transform 0.18s ease, filter 0.18s ease, opacity 0.18s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.01)',
|
||||||
|
filter: 'brightness(1.05)',
|
||||||
|
},
|
||||||
|
'&.Mui-disabled': {
|
||||||
|
background: 'rgba(255,255,255,0.10)',
|
||||||
|
color: 'rgba(255,255,255,0.55)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploadStatus === 'loading' ? 'Загрузка...' : 'Установить скин'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* RIGHT COLUMN */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1vw',
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: '44vw',
|
||||||
|
justifySelf: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Плащи */}
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: '1.6vw',
|
||||||
|
borderRadius: '1.2vw',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '1.35vw',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
backgroundImage: GRADIENT,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
mb: '1.0vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ваши плащи
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '1.2vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{capes.map((cape) => (
|
||||||
|
<CapeCard
|
||||||
|
key={cape.cape_id}
|
||||||
|
cape={cape}
|
||||||
|
mode="profile"
|
||||||
|
onAction={cape.is_active ? handleDeactivateCape : handleActivateCape}
|
||||||
|
actionDisabled={loading}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Онлайн */}
|
||||||
|
<OnlinePlayersPanel currentUsername={username} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
452
src/renderer/pages/Registration.tsx
Normal file
452
src/renderer/pages/Registration.tsx
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
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,
|
||||||
|
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 QRCodeStyling from 'qr-code-styling';
|
||||||
|
import {
|
||||||
|
generateVerificationCode,
|
||||||
|
registerUser,
|
||||||
|
getVerificationStatus,
|
||||||
|
} from '../api';
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
574
src/renderer/pages/Settings.tsx
Normal file
574
src/renderer/pages/Settings.tsx
Normal file
@ -0,0 +1,574 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
Slider,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Chip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||||
|
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||||
|
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
|
||||||
|
|
||||||
|
type SettingsState = {
|
||||||
|
// UI
|
||||||
|
uiScale: number; // 80..120
|
||||||
|
reduceMotion: boolean;
|
||||||
|
blurEffects: boolean;
|
||||||
|
|
||||||
|
// Launcher / app
|
||||||
|
autoUpdate: boolean;
|
||||||
|
startInTray: boolean;
|
||||||
|
|
||||||
|
// Game
|
||||||
|
autoRotateSkinViewer: boolean;
|
||||||
|
walkingSpeed: number; // 0..1
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
notifications: boolean;
|
||||||
|
notificationPosition: 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left';
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'launcher_settings';
|
||||||
|
|
||||||
|
const GRADIENT =
|
||||||
|
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||||
|
|
||||||
|
const defaultSettings: SettingsState = {
|
||||||
|
uiScale: 100,
|
||||||
|
reduceMotion: false,
|
||||||
|
blurEffects: true,
|
||||||
|
|
||||||
|
autoUpdate: true,
|
||||||
|
startInTray: false,
|
||||||
|
|
||||||
|
autoRotateSkinViewer: true,
|
||||||
|
walkingSpeed: 0.5,
|
||||||
|
|
||||||
|
notifications: true,
|
||||||
|
notificationPosition: 'bottom-center',
|
||||||
|
};
|
||||||
|
|
||||||
|
function safeParseSettings(raw: string | null): SettingsState | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(raw);
|
||||||
|
return {
|
||||||
|
...defaultSettings,
|
||||||
|
...obj,
|
||||||
|
} as SettingsState;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔽 ВСТАВИТЬ СЮДА (выше Settings)
|
||||||
|
const NotificationPositionPicker = ({
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: SettingsState['notificationPosition'];
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (v: SettingsState['notificationPosition']) => void;
|
||||||
|
}) => {
|
||||||
|
const POSITIONS = [
|
||||||
|
{ key: 'top-left', label: 'Сверху слева', align: 'flex-start', justify: 'flex-start' },
|
||||||
|
{ key: 'top-center', label: 'Сверху по-центру', align: 'flex-start', justify: 'center' },
|
||||||
|
{ key: 'top-right', label: 'Сверху справа', align: 'flex-start', justify: 'flex-end' },
|
||||||
|
{ key: 'bottom-left', label: 'Снизу слева', align: 'flex-end', justify: 'flex-start' },
|
||||||
|
{ key: 'bottom-center', label: 'Снизу по-центру', align: 'flex-end', justify: 'center' },
|
||||||
|
{ key: 'bottom-right', label: 'Снизу справа', align: 'flex-end', justify: 'flex-end' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ opacity: disabled ? 0.45 : 1, pointerEvents: disabled ? 'none' : 'auto' }}>
|
||||||
|
<Typography sx={{ fontFamily: 'Benzin-Bold', mb: '0.8vw', color: 'rgba(255,255,255,0.75)' }}>
|
||||||
|
Позиция уведомлений
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: '1.2vw',
|
||||||
|
p: '0.9vw',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
background: 'rgba(0,0,0,0.22)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gridTemplateRows: 'repeat(2, 8vw)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{POSITIONS.map((p) => {
|
||||||
|
const selected = value === p.key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={p.key}
|
||||||
|
onClick={() => onChange(p.key)}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
//borderRadius: '0.9vw',
|
||||||
|
border: selected
|
||||||
|
? '1px solid rgba(233,64,205,0.55)'
|
||||||
|
: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
background: selected
|
||||||
|
? 'linear-gradient(120deg, rgba(242,113,33,0.12), rgba(233,64,205,0.10))'
|
||||||
|
: 'rgba(255,255,255,0.04)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: p.align,
|
||||||
|
justifyContent: p.justify,
|
||||||
|
p: '0.6vw',
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* мини-уведомление */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '75%',
|
||||||
|
borderRadius: '0.8vw',
|
||||||
|
px: '0.7vw',
|
||||||
|
py: '0.5vw',
|
||||||
|
background: 'rgba(10,10,20,0.9)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
|
boxShadow: '0 0.8vw 2vw rgba(0,0,0,0.45)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ height: '0.45vw', width: '60%', background: '#fff', borderRadius: 99 }} />
|
||||||
|
<Box sx={{ mt: '0.3vw', height: '0.4vw', width: '85%', background: '#aaa', borderRadius: 99 }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapNotifPosition = (
|
||||||
|
p: SettingsState['notificationPosition'],
|
||||||
|
): NotificationPosition => {
|
||||||
|
const [vertical, horizontal] = p.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right'];
|
||||||
|
return { vertical, horizontal };
|
||||||
|
};
|
||||||
|
|
||||||
|
const Settings = () => {
|
||||||
|
const [lastSavedSettings, setLastSavedSettings] = useState<SettingsState>(() => {
|
||||||
|
if (typeof window === 'undefined') return defaultSettings;
|
||||||
|
return safeParseSettings(localStorage.getItem(STORAGE_KEY)) ?? defaultSettings;
|
||||||
|
});
|
||||||
|
const [notifOpen, setNotifOpen] = useState(false);
|
||||||
|
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||||||
|
const [notifSeverity, setNotifSeverity] = useState<
|
||||||
|
'success' | 'info' | 'warning' | 'error'
|
||||||
|
>('info');
|
||||||
|
|
||||||
|
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState<SettingsState>(() => {
|
||||||
|
if (typeof window === 'undefined') return defaultSettings;
|
||||||
|
return safeParseSettings(localStorage.getItem(STORAGE_KEY)) ?? defaultSettings;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dirty = useMemo(() => {
|
||||||
|
return JSON.stringify(settings) !== JSON.stringify(lastSavedSettings);
|
||||||
|
}, [settings, lastSavedSettings]);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
|
||||||
|
setLastSavedSettings(settings);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('settings-updated'));
|
||||||
|
|
||||||
|
// если уведомления выключены — НЕ показываем нотификацию
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Настройки успешно сохранены!');
|
||||||
|
setNotifSeverity('info');
|
||||||
|
setNotifPos(mapNotifPosition(settings.notificationPosition));
|
||||||
|
setNotifOpen(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Не удалось сохранить настройки', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setSettings(defaultSettings);
|
||||||
|
setLastSavedSettings(defaultSettings);
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultSettings));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Не удалось сбросить настройки', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkNotif = () => {
|
||||||
|
if (!settings.notifications) return; // если выключены — не показываем
|
||||||
|
|
||||||
|
setNotifMsg('Проверка уведомления!');
|
||||||
|
setNotifSeverity('info');
|
||||||
|
setNotifPos(mapNotifPosition(settings.notificationPosition)); // 👈 важно
|
||||||
|
setNotifOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// motion / blur классы — глобально на body
|
||||||
|
document.body.classList.toggle('reduce-motion', settings.reduceMotion);
|
||||||
|
document.body.classList.toggle('no-blur', !settings.blurEffects);
|
||||||
|
}, [settings.reduceMotion, settings.blurEffects]);
|
||||||
|
|
||||||
|
const SectionTitle = ({ children }: { children: string }) => (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '1.25vw',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
backgroundImage: GRADIENT,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
mb: '0.9vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Glass = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<Paper
|
||||||
|
className="glass glass--soft"
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '1.2vw',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: '1.8vw' }}>{children}</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const controlSx = {
|
||||||
|
'& .MuiFormControlLabel-label': {
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
color: 'rgba(255,255,255,0.88)',
|
||||||
|
},
|
||||||
|
'& .MuiSwitch-switchBase.Mui-checked': {
|
||||||
|
color: 'rgba(242,113,33,0.95)',
|
||||||
|
},
|
||||||
|
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
|
||||||
|
backgroundColor: 'rgba(233,64,205,0.55)',
|
||||||
|
},
|
||||||
|
'& .MuiSwitch-track': {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.20)',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.toggle('no-blur', !settings.blurEffects);
|
||||||
|
}, [settings.blurEffects]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: '2vw',
|
||||||
|
pb: '2vw',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CustomNotification
|
||||||
|
open={notifOpen}
|
||||||
|
message={notifMsg}
|
||||||
|
severity={notifSeverity}
|
||||||
|
position={notifPos}
|
||||||
|
onClose={() => setNotifOpen(false)}
|
||||||
|
autoHideDuration={2500}
|
||||||
|
/>
|
||||||
|
{/* header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: '1.2vw',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1vw',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: '0.8vw', alignItems: 'center' }}>
|
||||||
|
{dirty && (
|
||||||
|
<Chip
|
||||||
|
label="Есть несохранённые изменения"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: '1.6rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 900,
|
||||||
|
background:
|
||||||
|
'linear-gradient(120deg, rgba(242,113,33,0.24), rgba(233,64,205,0.16), rgba(138,35,135,0.20))',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={reset}
|
||||||
|
disableRipple
|
||||||
|
sx={{
|
||||||
|
borderRadius: '999px',
|
||||||
|
px: '1.2vw',
|
||||||
|
py: '0.6vw',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
color: 'rgba(255,255,255,0.92)',
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
'&:hover': { background: 'rgba(255,255,255,0.12)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={save}
|
||||||
|
disableRipple
|
||||||
|
disabled={!dirty}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '999px',
|
||||||
|
px: '1.2vw',
|
||||||
|
py: '0.6vw',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
color: '#fff',
|
||||||
|
background: GRADIENT,
|
||||||
|
opacity: dirty ? 1 : 0.5,
|
||||||
|
'&:hover': { filter: 'brightness(1.05)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
|
||||||
|
gap: '2vw',
|
||||||
|
alignItems: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* LEFT */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '2vw', minWidth: 0, width: '43vw' }}>
|
||||||
|
<Glass>
|
||||||
|
<SectionTitle>Интерфейс</SectionTitle>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.88)' }}>
|
||||||
|
Масштаб интерфейса: {settings.uiScale}%
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={settings.uiScale}
|
||||||
|
min={80}
|
||||||
|
max={120}
|
||||||
|
step={5}
|
||||||
|
onChange={(_, v) => setSettings((s) => ({ ...s, uiScale: v as number }))}
|
||||||
|
sx={{
|
||||||
|
mt: 0.4,
|
||||||
|
'& .MuiSlider-thumb': { boxShadow: '0 10px 22px rgba(0,0,0,0.45)' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={settings.reduceMotion}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, reduceMotion: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Уменьшить анимации"
|
||||||
|
sx={controlSx}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={settings.blurEffects}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, blurEffects: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Эффекты размытия (blur)"
|
||||||
|
sx={controlSx}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Glass>
|
||||||
|
|
||||||
|
<Glass>
|
||||||
|
<SectionTitle>Уведомления</SectionTitle>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, notifications: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Включить уведомления"
|
||||||
|
sx={controlSx}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NotificationPositionPicker
|
||||||
|
value={settings.notificationPosition}
|
||||||
|
disabled={!settings.notifications}
|
||||||
|
onChange={(pos) =>
|
||||||
|
setSettings((s) => ({
|
||||||
|
...s,
|
||||||
|
notificationPosition: pos,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<Box sx={{display: 'flex', flexWrap: 'wrap'}}>
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.9vw' }}>
|
||||||
|
<span onClick={checkNotif} style={{borderBottom: '1px solid #ccc', cursor: 'pointer'}}>Нажмите сюда,</span> чтобы проверить уведомление.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Glass>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* RIGHT */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '2vw', minWidth: 0 }}>
|
||||||
|
<Glass>
|
||||||
|
<SectionTitle>Игра</SectionTitle>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={settings.autoRotateSkinViewer}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, autoRotateSkinViewer: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Автоповорот персонажа в профиле"
|
||||||
|
sx={controlSx}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.88)' }}>
|
||||||
|
Скорость ходьбы в просмотрщике: {settings.walkingSpeed.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={settings.walkingSpeed}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
onChange={(_, v) => setSettings((s) => ({ ...s, walkingSpeed: v as number }))}
|
||||||
|
sx={{ mt: 0.4 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.9vw' }}>
|
||||||
|
Эти значения можно прокинуть в Profile: autoRotate и walkingSpeed.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Glass>
|
||||||
|
|
||||||
|
<Glass>
|
||||||
|
<SectionTitle>Лаунчер</SectionTitle>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={settings.autoUpdate}
|
||||||
|
onChange={(e) => setSettings((s) => ({ ...s, autoUpdate: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Автообновление данных (где поддерживается)"
|
||||||
|
sx={controlSx}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={settings.startInTray}
|
||||||
|
onChange={(e) => setSettings((s) => ({ ...s, startInTray: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Запускать свернутым (в трей)"
|
||||||
|
sx={controlSx}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
// просто пример действия
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('launcher_cache');
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
disableRipple
|
||||||
|
sx={{
|
||||||
|
borderRadius: '999px',
|
||||||
|
py: '0.8vw',
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
color: '#fff',
|
||||||
|
background: 'rgba(255,255,255,0.10)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
'&:hover': { background: 'rgba(255,255,255,0.14)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Очистить кэш (пример)
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.9vw' }}>
|
||||||
|
Кнопка-заглушка: можно подключить к вашим реальным ключам localStorage.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Glass>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
701
src/renderer/pages/Shop.tsx
Normal file
701
src/renderer/pages/Shop.tsx
Normal file
@ -0,0 +1,701 @@
|
|||||||
|
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';
|
||||||
|
import { playBuySound, primeSounds } from '../utils/sounds';
|
||||||
|
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||||
|
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||||
|
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
|
||||||
|
|
||||||
|
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 [notifOpen, setNotifOpen] = useState(false);
|
||||||
|
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||||||
|
const [notifSeverity, setNotifSeverity] = useState<
|
||||||
|
'success' | 'info' | 'warning' | 'error'
|
||||||
|
>('info');
|
||||||
|
|
||||||
|
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Прокачка
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Ошибка при загрузке прокачки!');
|
||||||
|
setNotifSeverity('error');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
} 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);
|
||||||
|
|
||||||
|
playBuySound();
|
||||||
|
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Плащ успешно куплен!');
|
||||||
|
setNotifSeverity('success');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при покупке плаща:', error);
|
||||||
|
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Ошибка при покупке плаща!');
|
||||||
|
setNotifSeverity('error');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Загрузка кейсов
|
||||||
|
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) {
|
||||||
|
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Не найдено имя игрока. Авторизируйтесь в лаунчере!');
|
||||||
|
setNotifSeverity('error');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withProcessing(bonusTypeId, async () => {
|
||||||
|
try {
|
||||||
|
const res = await purchaseBonus(username, bonusTypeId);
|
||||||
|
|
||||||
|
playBuySound();
|
||||||
|
|
||||||
|
await loadBonuses(username);
|
||||||
|
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Прокачка успешно куплена!');
|
||||||
|
setNotifSeverity('success');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при покупке прокачки:', error);
|
||||||
|
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Ошибка при прокачке!');
|
||||||
|
setNotifSeverity('error');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpgradeBonus = async (bonusId: string) => {
|
||||||
|
if (!username) return;
|
||||||
|
|
||||||
|
await withProcessing(bonusId, async () => {
|
||||||
|
try {
|
||||||
|
await upgradeBonus(username, bonusId);
|
||||||
|
|
||||||
|
await loadBonuses(username);
|
||||||
|
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Бонус улучшен!');
|
||||||
|
setNotifSeverity('success');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при улучшении бонуса:', error);
|
||||||
|
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Ошибка при улучшении бонуса!');
|
||||||
|
setNotifSeverity('error');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Ошибка при переключении бонуса!');
|
||||||
|
setNotifSeverity('error');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Фильтруем плащи, которые уже куплены пользователем
|
||||||
|
const availableCapes = storeCapes.filter(
|
||||||
|
(storeCape) =>
|
||||||
|
!userCapes.some((userCape) => userCape.cape_id === storeCape.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenCase = async (caseData: Case) => {
|
||||||
|
if (!username) {
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Не найдено имя игрока. Авторизуйтесь в лаунчере!');
|
||||||
|
setNotifSeverity('error');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOnline || !playerServer) {
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Для открытия кейсов необходимо находиться на сервере в игре!');
|
||||||
|
setNotifSeverity('error');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
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);
|
||||||
|
playBuySound();
|
||||||
|
|
||||||
|
setIsOpening(false);
|
||||||
|
|
||||||
|
// 4. уведомление
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Кейс открыт!');
|
||||||
|
setNotifSeverity('success');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при открытии кейса:', error);
|
||||||
|
|
||||||
|
setIsOpening(false);
|
||||||
|
|
||||||
|
if (!isNotificationsEnabled()) return;
|
||||||
|
setNotifMsg('Ошибка при открытии кейса!');
|
||||||
|
setNotifSeverity('error');
|
||||||
|
setNotifPos({ vertical: 'top', horizontal: 'center' });
|
||||||
|
setNotifOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseNotification = () => {
|
||||||
|
setNotification((prev) => ({ ...prev, open: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseRoulette = () => {
|
||||||
|
setRouletteOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onFirstUserGesture = () => primeSounds();
|
||||||
|
|
||||||
|
window.addEventListener('pointerdown', onFirstUserGesture, { once: true });
|
||||||
|
return () => window.removeEventListener('pointerdown', onFirstUserGesture);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '2vw',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(loading || onlineCheckLoading) && (
|
||||||
|
<FullScreenLoader message="Загрузка магазина..." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !onlineCheckLoading && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
gap: '2vw',
|
||||||
|
overflow: 'auto',
|
||||||
|
paddingBottom: '5vw',
|
||||||
|
paddingLeft: '2.5vw',
|
||||||
|
paddingRight: '1.5vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Блок прокачки */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
mt: '2vh'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
disableRipple
|
||||||
|
disableFocusRipple
|
||||||
|
disableTouchRipple
|
||||||
|
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.02)',
|
||||||
|
},
|
||||||
|
border: 'none',
|
||||||
|
}}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Уведомления */}
|
||||||
|
<CustomNotification
|
||||||
|
open={notifOpen}
|
||||||
|
message={notifMsg}
|
||||||
|
severity={notifSeverity}
|
||||||
|
position={notifPos}
|
||||||
|
onClose={() => setNotifOpen(false)}
|
||||||
|
autoHideDuration={2500}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
488
src/renderer/pages/VersionsExplorer.tsx
Normal file
488
src/renderer/pages/VersionsExplorer.tsx
Normal file
@ -0,0 +1,488 @@
|
|||||||
|
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',
|
||||||
|
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',
|
||||||
|
position: 'relative',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 14px 40px rgba(0, 0, 0, 0.6)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 0.35s ease, box-shadow 0.35s ease',
|
||||||
|
willChange: 'transform, box-shadow',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: '0 0 40px rgba(242,113,33,0.7)',
|
||||||
|
transform: 'scale(1.02)',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={handleAddVersion}
|
||||||
|
>
|
||||||
|
<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={{
|
||||||
|
px: '5vw',
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '2vh',
|
||||||
|
height: '100%',
|
||||||
|
width: '85%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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%)',
|
||||||
|
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(20,20,20,0.2)',
|
||||||
|
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.01)',
|
||||||
|
boxShadow: '0 10px 30px rgba(0,0,0,0.6)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
39
src/renderer/utils/notifications.ts
Normal file
39
src/renderer/utils/notifications.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||||
|
|
||||||
|
export function isNotificationsEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem('launcher_settings') || '{}');
|
||||||
|
return s.notifications !== false; // по умолчанию true
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function positionFromSettingValue(
|
||||||
|
v: string | undefined,
|
||||||
|
): NotificationPosition {
|
||||||
|
switch (v) {
|
||||||
|
case 'top-left':
|
||||||
|
return { vertical: 'top', horizontal: 'left' };
|
||||||
|
case 'top-center':
|
||||||
|
return { vertical: 'top', horizontal: 'center' };
|
||||||
|
case 'top-right':
|
||||||
|
return { vertical: 'top', horizontal: 'right' };
|
||||||
|
case 'bottom-left':
|
||||||
|
return { vertical: 'bottom', horizontal: 'left' };
|
||||||
|
case 'bottom-center':
|
||||||
|
return { vertical: 'bottom', horizontal: 'center' };
|
||||||
|
case 'bottom-right':
|
||||||
|
default:
|
||||||
|
return { vertical: 'bottom', horizontal: 'right' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotifPositionFromSettings(): NotificationPosition {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem('launcher_settings') || '{}');
|
||||||
|
return positionFromSettingValue(s.notificationPosition);
|
||||||
|
} catch {
|
||||||
|
return { vertical: 'top', horizontal: 'right' };
|
||||||
|
}
|
||||||
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/renderer/utils/serverTranslator.ts
Normal file
13
src/renderer/utils/serverTranslator.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// src/renderer/utils/serverTranslator.ts
|
||||||
|
export function translateServer(serverName: string): string {
|
||||||
|
switch (serverName) {
|
||||||
|
case 'Server minecraft.hub.popa-popa.ru':
|
||||||
|
return 'Хаб';
|
||||||
|
case 'Server minecraft.survival.popa-popa.ru':
|
||||||
|
return 'Выживание';
|
||||||
|
case 'Server minecraft.minigames.popa-popa.ru':
|
||||||
|
return 'Миниигры';
|
||||||
|
default:
|
||||||
|
return serverName;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/renderer/utils/sounds.ts
Normal file
52
src/renderer/utils/sounds.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import buySound from '../assets/sounds/buy.mp3';
|
||||||
|
|
||||||
|
let buyAudio: HTMLAudioElement | null = null;
|
||||||
|
let unlocked = false;
|
||||||
|
|
||||||
|
export const primeSounds = () => {
|
||||||
|
try {
|
||||||
|
if (unlocked) return;
|
||||||
|
|
||||||
|
if (!buyAudio) {
|
||||||
|
buyAudio = new Audio(buySound);
|
||||||
|
buyAudio.volume = 0; // тихо, чтобы не слышно
|
||||||
|
}
|
||||||
|
|
||||||
|
// попытка "разлочить" аудио в контексте user gesture
|
||||||
|
const p = buyAudio.play();
|
||||||
|
if (p && typeof (p as Promise<void>).then === 'function') {
|
||||||
|
(p as Promise<void>)
|
||||||
|
.then(() => {
|
||||||
|
buyAudio?.pause();
|
||||||
|
if (buyAudio) buyAudio.currentTime = 0;
|
||||||
|
if (buyAudio) buyAudio.volume = 0.6; // вернуть норм громкость
|
||||||
|
unlocked = true;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// если заблокировано — попробуем снова при следующем клике
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// на всякий: если play синхронный
|
||||||
|
buyAudio.pause();
|
||||||
|
buyAudio.currentTime = 0;
|
||||||
|
buyAudio.volume = 0.6;
|
||||||
|
unlocked = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// не ломаем UI
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const playBuySound = () => {
|
||||||
|
try {
|
||||||
|
if (!buyAudio) {
|
||||||
|
buyAudio = new Audio(buySound);
|
||||||
|
buyAudio.volume = 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
buyAudio.currentTime = 0;
|
||||||
|
buyAudio.play().catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// игнор
|
||||||
|
}
|
||||||
|
};
|
||||||
176
src/theme/themes.ts
Normal file
176
src/theme/themes.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { createTheme, SxProps, Theme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
declare module '@mui/material/styles' {
|
||||||
|
interface Theme {
|
||||||
|
launcher: {
|
||||||
|
fonts: {
|
||||||
|
default: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
gradients: {
|
||||||
|
accent: string;
|
||||||
|
tabs: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
topbar: {
|
||||||
|
firstBox: SxProps<Theme>;
|
||||||
|
backButton: SxProps<Theme>;
|
||||||
|
tabsBox: {
|
||||||
|
borderColor: string;
|
||||||
|
};
|
||||||
|
tabs: SxProps<Theme>;
|
||||||
|
tabBase: SxProps<Theme>;
|
||||||
|
tabActive: SxProps<Theme>;
|
||||||
|
menuPaper: SxProps<Theme>;
|
||||||
|
menuDivider: SxProps<Theme>;
|
||||||
|
menuItem: SxProps<Theme>;
|
||||||
|
menuUsername: SxProps<Theme>;
|
||||||
|
logoutButton: SxProps<Theme>;
|
||||||
|
windowControlIcon: {
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
windowControlButton: SxProps<Theme>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeOptions {
|
||||||
|
launcher?: Theme['launcher'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultTheme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'dark',
|
||||||
|
primary: { main: '#F27121' },
|
||||||
|
secondary: { main: '#E940CD' },
|
||||||
|
text: {
|
||||||
|
primary: '#FFFFFF',
|
||||||
|
secondary: 'rgba(255,255,255,0.7)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
launcher: {
|
||||||
|
fonts: {
|
||||||
|
default: 'Benzin-Bold',
|
||||||
|
},
|
||||||
|
|
||||||
|
gradients: {
|
||||||
|
accent: '#F27121 0%, #E940CD 50%, #8A2387 100%',
|
||||||
|
tabs: 'linear-gradient(71deg, rgba(242,113,33,0.18) 0%, rgba(233,64,205,0.14) 70%, rgba(138,35,135,0.16) 100%)',
|
||||||
|
},
|
||||||
|
|
||||||
|
topbar: {
|
||||||
|
firstBox: (theme: Theme) => ({
|
||||||
|
background: theme.launcher.gradients.tabs,
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
boxShadow: '0 8px 30px rgba(0,0,0,0.35)',
|
||||||
|
}),
|
||||||
|
backButton: (theme: Theme) => ({
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
transition: 'transform 0.3s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.2)',
|
||||||
|
},
|
||||||
|
border: 'unset',
|
||||||
|
}),
|
||||||
|
tabsBox: {
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
// один градиент на весь Tabs
|
||||||
|
'--tabs-grad':
|
||||||
|
'linear-gradient(90deg, #F27121 0%, #E940CD 50%, #8A2387 100%)',
|
||||||
|
|
||||||
|
// активный текст показывает “срез” общего градиента
|
||||||
|
'& .MuiTab-root.Mui-selected': {
|
||||||
|
color: 'transparent',
|
||||||
|
backgroundImage: 'var(--tabs-grad)',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'var(--tabs-w) 100%',
|
||||||
|
backgroundPosition: 'calc(-1 * var(--active-x)) 0',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
},
|
||||||
|
|
||||||
|
// подчёркивание тоже из того же “единого” градиента
|
||||||
|
'& .MuiTabs-indicator': {
|
||||||
|
height: '2px',
|
||||||
|
backgroundImage: 'var(--tabs-grad)',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'var(--tabs-w) 100%',
|
||||||
|
backgroundPosition: 'calc(-1 * var(--active-x)) 0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tabBase: (theme: Theme) => ({
|
||||||
|
color: 'white',
|
||||||
|
fontFamily: theme.launcher.fonts.default,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'rgb(170, 170, 170)',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tabActive: {
|
||||||
|
color: 'transparent',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundImage: 'var(--tabs-grad)',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'var(--tabs-w) 100%',
|
||||||
|
backgroundPosition: 'calc(-1 * var(--active-x)) 0',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
},
|
||||||
|
menuPaper: {
|
||||||
|
color: 'white',
|
||||||
|
bgcolor: 'rgba(0,0,0,0.82)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
border: '1px solid rgba(233,64,205,0.25)',
|
||||||
|
boxShadow: '0 18px 40px rgba(0,0,0,0.55)',
|
||||||
|
},
|
||||||
|
|
||||||
|
menuDivider: {
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
},
|
||||||
|
|
||||||
|
menuItem: (theme: Theme) => ({
|
||||||
|
fontFamily: theme.launcher.fonts.default,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(255,77,77,0.15)',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
menuUsername: (theme: Theme) => ({
|
||||||
|
fontFamily: theme.launcher.fonts.default,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
}),
|
||||||
|
|
||||||
|
logoutButton: (theme: Theme) => ({
|
||||||
|
fontFamily: theme.launcher.fonts.default,
|
||||||
|
borderRadius: '2.5vw',
|
||||||
|
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.01)',
|
||||||
|
boxShadow: '0 4px 15px rgba(242, 113, 33, 0.4)',
|
||||||
|
},
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
||||||
|
}),
|
||||||
|
|
||||||
|
windowControlIcon: {
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
|
||||||
|
windowControlButton: {
|
||||||
|
// тут только “визуал”, размеры оставим в TopBar
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(255,255,255,0.06)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user