Compare commits
103 Commits
3b13d78cdc
...
voice_test
| Author | SHA1 | Date | |
|---|---|---|---|
| f617940c44 | |||
| 53879dfaac | |||
| 8520f2120d | |||
| 4944a18076 | |||
| a76a8b5656 | |||
| d90ef2e535 | |||
| 1348b39a4c | |||
| c77d173fce | |||
| 687e2db51b | |||
| 5dc1744cfd | |||
| 614f6d0a25 | |||
| 8d9f55f601 | |||
| c449e99542 | |||
| dc4fe3b18e | |||
| e0889cfaea | |||
| b1f378c5a8 | |||
| 287116103d | |||
| 2777e9b008 | |||
| 1a78b95524 | |||
| 4efa8c4437 | |||
| a4a10c164e | |||
| 8003e3567a | |||
| c5ea4ca38f | |||
| 7fbb9fa78a | |||
| 6caa563b41 | |||
| 4b8e535c58 | |||
| 779f8f779d | |||
| f8b358d9bd | |||
| 5f23adc9ae | |||
| b1d369e49d | |||
| acd46bb31c | |||
| 26f0865d2e | |||
| d25cda62df | |||
| 64b6129713 | |||
| 6edf7ca7d0 | |||
| 3aa99e7262 | |||
| fef89513c2 | |||
| 24423173a6 | |||
| 70ec57d6fb | |||
| c15c36891e | |||
| 6db213d602 | |||
| cd7ad5039e | |||
| 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 |
@ -112,6 +112,13 @@ const configuration: webpack.Configuration = {
|
||||
'file-loader',
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(mp3|wav|ogg)$/i,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
filename: 'assets/sounds/[name][ext]',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@ -88,6 +88,13 @@ const configuration: webpack.Configuration = {
|
||||
'file-loader',
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(mp3|wav|ogg)$/i,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
filename: 'assets/sounds/[name][ext]',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
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
|
||||
[stackoverflow-img]: https://img.shields.io/badge/stackoverflow-electron_react_boilerplate-blue.svg
|
||||
[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/icon.ico
BIN
assets/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 13 KiB |
BIN
assets/images/fake-payment.png
Normal file
BIN
assets/images/fake-payment.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
8654
package-lock.json
generated
8654
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@ -55,6 +55,9 @@
|
||||
"browserslist": [
|
||||
"extends browserslist-config-erb"
|
||||
],
|
||||
"overrides": {
|
||||
"undici": "6.10.2"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
@ -102,15 +105,16 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/notarize": "^3.0.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.2.0",
|
||||
"@mui/material": "^7.2.0",
|
||||
"@xmcl/core": "^2.14.1",
|
||||
"@xmcl/installer": "^6.1.0",
|
||||
"@xmcl/file-transfer": "^2.0.3",
|
||||
"@xmcl/installer": "^6.1.2",
|
||||
"@xmcl/resourcepack": "^1.2.4",
|
||||
"@xmcl/user": "^4.2.0",
|
||||
"easymde": "^2.20.0",
|
||||
"electron-debug": "^4.1.0",
|
||||
"electron-log": "^5.3.2",
|
||||
"electron-updater": "^6.3.9",
|
||||
@ -120,11 +124,14 @@
|
||||
"qr-code-styling": "^1.9.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"skinview3d": "^3.4.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"three": "^0.178.0",
|
||||
"undici": "^7.11.0",
|
||||
"util": "^0.12.5",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
@ -199,7 +206,6 @@
|
||||
"productName": "popa-launcher",
|
||||
"appId": "org.erb.ElectronReact",
|
||||
"asar": true,
|
||||
"afterSign": ".erb/scripts/notarize.js",
|
||||
"asarUnpack": "**\\*.{node,dll}",
|
||||
"files": [
|
||||
"dist",
|
||||
@ -256,7 +262,7 @@
|
||||
],
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://git.popa-popa.ru/DIKER/popa-launcher/releases/download/v${version}",
|
||||
"url": "https://git.popa-popa.ru/DIKER/popa-launcher/releases/download/latest",
|
||||
"channel": "latest",
|
||||
"requestHeaders": {
|
||||
"Authorization": "token ${env.GH_TOKEN}"
|
||||
|
||||
4
release/app/package-lock.json
generated
4
release/app/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "popa-launcher",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.6",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "popa-launcher",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "popa-launcher",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.6",
|
||||
"description": "Popa Launcher",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@ -35,8 +35,14 @@ export class AuthService {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Ошибка авторизации: ${response.status} ${errorText}`);
|
||||
let detail = '';
|
||||
try {
|
||||
const data = await response.json(); // FastAPI: { detail: "..." }
|
||||
detail = data?.detail || '';
|
||||
} catch {
|
||||
detail = await response.text();
|
||||
}
|
||||
throw new Error(detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const auth = await response.json();
|
||||
|
||||
178
src/main/main.ts
178
src/main/main.ts
@ -9,7 +9,7 @@
|
||||
* `./src/main.js` using webpack. This gives us some performance wins.
|
||||
*/
|
||||
import path from 'path';
|
||||
import { app, BrowserWindow, shell, ipcMain } from 'electron';
|
||||
import { app, BrowserWindow, shell, ipcMain, Tray, Menu, nativeImage, type MenuItemConstructorOptions } from 'electron';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import log from 'electron-log';
|
||||
import MenuBuilder from './menu';
|
||||
@ -58,8 +58,128 @@ class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
let launcherSettings = {
|
||||
autoLaunch: false,
|
||||
startInTray: false,
|
||||
closeToTray: true,
|
||||
};
|
||||
|
||||
const ensureTray = () => {
|
||||
if (tray) return;
|
||||
|
||||
const RESOURCES_PATH = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'assets')
|
||||
: path.join(__dirname, '../../assets');
|
||||
|
||||
const getAssetPath = (...paths: string[]) => path.join(RESOURCES_PATH, ...paths);
|
||||
|
||||
const trayIconPath = getAssetPath('pop-popa.png'); // или 'Icons/popa-popa.png'
|
||||
const trayImage = nativeImage.createFromPath(trayIconPath);
|
||||
|
||||
tray = new Tray(trayImage);
|
||||
tray.setToolTip('popa-launcher');
|
||||
tray.on('click', () => {
|
||||
if (!mainWindow) return;
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Открыть',
|
||||
click: () => {
|
||||
if (!mainWindow) return;
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Выход',
|
||||
click: () => {
|
||||
isQuitting = true;
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.removeAllListeners('close');
|
||||
mainWindow.destroy(); // ⬅ КЛЮЧЕВО
|
||||
}
|
||||
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setContextMenu(menu);
|
||||
|
||||
tray.on('double-click', () => {
|
||||
if (!mainWindow) return;
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const applyLoginItemSettings = () => {
|
||||
// Работает на Windows/macOS. На Linux зависит от окружения.
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: launcherSettings.autoLaunch,
|
||||
openAsHidden: launcherSettings.startInTray, // чтобы стартовал скрытым
|
||||
});
|
||||
};
|
||||
|
||||
let tray: Tray | null = null;
|
||||
let isAuthed = false;
|
||||
let isQuitting = false;
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function buildTrayMenu() {
|
||||
const icon = nativeImage.createFromPath(
|
||||
app.isPackaged
|
||||
? path.join(__dirname, '../../assets/popa-popa.png')
|
||||
: path.join(process.resourcesPath, 'assets', 'popa-popa.png'),
|
||||
);
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
{ label: 'popa-popa', enabled: false, icon },
|
||||
{ type: 'separator' },
|
||||
|
||||
...(isAuthed
|
||||
? ([
|
||||
{ label: 'Новости', click: () => mainWindow?.webContents.send('tray-navigate', '/news') },
|
||||
{ label: 'Версии', click: () => mainWindow?.webContents.send('tray-navigate', '/') },
|
||||
{ label: 'Магазин', click: () => mainWindow?.webContents.send('tray-navigate', '/shop') },
|
||||
{ label: 'Рынок', click: () => mainWindow?.webContents.send('tray-navigate', '/marketplace') },
|
||||
{ label: 'Профиль', click: () => mainWindow?.webContents.send('tray-navigate', '/profile') },
|
||||
{ label: 'Настройки', click: () => mainWindow?.webContents.send('tray-navigate', '/settings') },
|
||||
{ label: 'Ежедневная награда', click: () => mainWindow?.webContents.send('tray-navigate', '/daily') },
|
||||
{ label: 'Ежедневные квесты', click: () => mainWindow?.webContents.send('tray-navigate', '/dailyquests') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Выйти', click: () => mainWindow?.webContents.send('tray-logout') },
|
||||
] as MenuItemConstructorOptions[])
|
||||
: ([
|
||||
{ label: 'Войти', click: () => mainWindow?.webContents.send('tray-navigate', '/login') },
|
||||
] as MenuItemConstructorOptions[])),
|
||||
|
||||
{ type: 'separator' },
|
||||
{ label: 'Показать', click: () => { mainWindow?.show(); mainWindow?.focus(); } },
|
||||
{
|
||||
label: 'Выход',
|
||||
click: () => {
|
||||
isQuitting = true;
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.removeAllListeners('close');
|
||||
mainWindow.destroy(); // ⬅ КЛЮЧЕВО
|
||||
}
|
||||
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
tray?.setContextMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
ipcMain.on('ipc-example', async (event, arg) => {
|
||||
const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`;
|
||||
console.log(msgTemplate(arg));
|
||||
@ -101,6 +221,34 @@ const installExtensions = async () => {
|
||||
.catch(console.log);
|
||||
};
|
||||
|
||||
ipcMain.handle('apply-launcher-settings', (_e, payload) => {
|
||||
launcherSettings = {
|
||||
...launcherSettings,
|
||||
autoLaunch: Boolean(payload?.autoLaunch),
|
||||
startInTray: Boolean(payload?.startInTray),
|
||||
closeToTray: payload?.closeToTray === false ? false : true,
|
||||
};
|
||||
|
||||
applyLoginItemSettings();
|
||||
|
||||
// если попросили трей — убедимся что он есть
|
||||
if (launcherSettings.startInTray || launcherSettings.closeToTray) {
|
||||
ensureTray();
|
||||
}
|
||||
|
||||
// если окно уже создано и ещё не показано — решаем, показывать или нет
|
||||
if (mainWindow) {
|
||||
if (launcherSettings.startInTray) {
|
||||
// оставляем скрытым
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const createWindow = async () => {
|
||||
if (isDebug) {
|
||||
await installExtensions();
|
||||
@ -110,6 +258,8 @@ const createWindow = async () => {
|
||||
? path.join(process.resourcesPath, 'assets')
|
||||
: path.join(__dirname, '../../assets');
|
||||
|
||||
ensureTray();
|
||||
|
||||
const getAssetPath = (...paths: string[]): string => {
|
||||
return path.join(RESOURCES_PATH, ...paths);
|
||||
};
|
||||
@ -119,9 +269,9 @@ const createWindow = async () => {
|
||||
width: 1024,
|
||||
height: 850,
|
||||
autoHideMenuBar: true,
|
||||
resizable: true,
|
||||
resizable: false,
|
||||
frame: false,
|
||||
icon: getAssetPath('icon.png'),
|
||||
icon: getAssetPath('popa-popa.png'),
|
||||
webPreferences: {
|
||||
webSecurity: false,
|
||||
preload: app.isPackaged
|
||||
@ -130,6 +280,13 @@ const createWindow = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.on('close', (e) => {
|
||||
if (!isQuitting && launcherSettings.closeToTray) {
|
||||
e.preventDefault();
|
||||
mainWindow?.hide();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.loadURL(resolveHtmlPath('index.html'));
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
@ -139,7 +296,10 @@ const createWindow = async () => {
|
||||
if (process.env.START_MINIMIZED) {
|
||||
mainWindow.minimize();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
setTimeout(() => {
|
||||
if (!mainWindow) return;
|
||||
if (!launcherSettings.startInTray) mainWindow.show();
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
@ -178,6 +338,10 @@ app.on('window-all-closed', () => {
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
});
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
@ -193,3 +357,9 @@ app
|
||||
ipcMain.handle('install-update', () => {
|
||||
autoUpdater.quitAndInstall();
|
||||
});
|
||||
|
||||
ipcMain.handle('auth-changed', (_e, payload: { isAuthed: boolean }) => {
|
||||
isAuthed = Boolean(payload?.isAuthed);
|
||||
buildTrayMenu();
|
||||
return true;
|
||||
});
|
||||
|
||||
@ -6,18 +6,72 @@ import extract from 'extract-zip';
|
||||
import { launch, Version, diagnose } from '@xmcl/core';
|
||||
import { execSync } from 'child_process';
|
||||
import {
|
||||
installDependencies,
|
||||
installFabric,
|
||||
getFabricLoaders,
|
||||
getVersionList,
|
||||
install,
|
||||
installTask,
|
||||
installDependenciesTask,
|
||||
installNeoForged,
|
||||
} from '@xmcl/installer';
|
||||
import { Agent } from 'undici';
|
||||
import { spawn } from 'child_process';
|
||||
import { AuthService } from './auth-service';
|
||||
import { API_BASE_URL } from '../renderer/api';
|
||||
|
||||
app.setName('.popa-popa');
|
||||
|
||||
// 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 MCSTATUS_API_URL = 'https://api.mcstatus.io/v2/status/java/';
|
||||
@ -25,6 +79,13 @@ const MCSTATUS_API_URL = 'https://api.mcstatus.io/v2/status/java/';
|
||||
// Создаем экземпляр сервиса аутентификации
|
||||
const authService = new AuthService();
|
||||
|
||||
const agent = new Agent({
|
||||
connections: 16, // максимум 16 одновременных соединений (скачиваний)
|
||||
// тут можно задать и другие параметры при необходимости
|
||||
});
|
||||
|
||||
let currentMinecraftProcess: any | null = null;
|
||||
|
||||
// Модифицированная функция для получения последней версии релиза с произвольного URL
|
||||
export async function getLatestReleaseVersion(apiUrl: string): Promise<string> {
|
||||
try {
|
||||
@ -352,8 +413,8 @@ export function initMinecraftHandlers() {
|
||||
preserveFiles = [], // Новый параметр: список файлов/папок для сохранения
|
||||
} = options || {};
|
||||
|
||||
const appPath = path.dirname(app.getPath('exe'));
|
||||
const minecraftDir = path.join(appPath, '.minecraft');
|
||||
const userDataPath = app.getPath('userData');
|
||||
const minecraftDir = path.join(app.getPath('userData'), 'minecraft');
|
||||
const versionsDir = path.join(minecraftDir, 'versions');
|
||||
const versionFilePath = path.join(minecraftDir, versionFileName);
|
||||
|
||||
@ -376,7 +437,7 @@ export function initMinecraftHandlers() {
|
||||
};
|
||||
}
|
||||
|
||||
const tempDir = path.join(appPath, 'temp');
|
||||
const tempDir = path.join(userDataPath, 'temp');
|
||||
const packDir = path.join(versionsDir, packName); // Директория пакета
|
||||
|
||||
// Создаем/очищаем временную директорию
|
||||
@ -390,6 +451,9 @@ export function initMinecraftHandlers() {
|
||||
// Скачиваем файл
|
||||
await downloadFile(downloadUrl, zipPath, (progress) => {
|
||||
event.sender.send('download-progress', progress);
|
||||
|
||||
const global = getGlobalProgress('download', progress / 100);
|
||||
event.sender.send('overall-progress', global);
|
||||
});
|
||||
|
||||
// Проверяем архив
|
||||
@ -480,43 +544,95 @@ export function initMinecraftHandlers() {
|
||||
username,
|
||||
memory = 4096,
|
||||
baseVersion = '1.21.4',
|
||||
fabricVersion = '0.16.14',
|
||||
packName = 'Comfort', // Название основной сборки
|
||||
versionToLaunchOverride = '', // Возможность переопределить версию для запуска
|
||||
fabricVersion = null,
|
||||
neoForgeVersion = null,
|
||||
packName = 'Comfort', // имя сборки (папка с модами)
|
||||
versionToLaunchOverride = '', // переопределение версии для запуска (например, 1.21.10 для ванили)
|
||||
serverIp = 'popa-popa.ru',
|
||||
serverPort, // Добавляем опциональный порт без значения по умолчанию
|
||||
serverPort,
|
||||
isVanillaVersion = false,
|
||||
loaderType = 'fabric',
|
||||
} = gameConfig || {};
|
||||
|
||||
const appPath = path.dirname(app.getPath('exe'));
|
||||
const minecraftDir = path.join(appPath, '.minecraft');
|
||||
const userDataPath = app.getPath('userData');
|
||||
const minecraftDir = path.join(app.getPath('userData'), 'minecraft');
|
||||
const versionsDir = path.join(minecraftDir, 'versions');
|
||||
fs.mkdirSync(versionsDir, { recursive: true });
|
||||
|
||||
// gamePath:
|
||||
// - ваниль → .popa-popa
|
||||
// - модпак → .popa-popa/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)
|
||||
? fs.readdirSync(versionsDir)
|
||||
: [];
|
||||
console.log('Доступные версии:', versionsContents);
|
||||
|
||||
// Найти версию пакета, Fabric или базовую версию
|
||||
let versionToLaunch = versionToLaunchOverride;
|
||||
// --- Определяем базовую / фактическую версию ---
|
||||
let effectiveBaseVersion = baseVersion;
|
||||
|
||||
console.log('fabric:', `${baseVersion}-fabric${fabricVersion}`);
|
||||
// Для ванили считаем базовой именно ту, которую хотим запустить
|
||||
if (isVanillaVersion && versionToLaunchOverride) {
|
||||
effectiveBaseVersion = versionToLaunchOverride;
|
||||
}
|
||||
|
||||
let versionToLaunch: string | undefined =
|
||||
versionToLaunchOverride || undefined;
|
||||
|
||||
if (!versionToLaunch) {
|
||||
if (
|
||||
versionsContents.includes(`${baseVersion}-fabric${fabricVersion}`)
|
||||
) {
|
||||
versionToLaunch = `${baseVersion}-fabric${fabricVersion}`;
|
||||
} else if (versionsContents.includes(packName)) {
|
||||
versionToLaunch = packName;
|
||||
if (isVanillaVersion) {
|
||||
// Ваниль — запускаем baseVersion (или override)
|
||||
versionToLaunch = effectiveBaseVersion;
|
||||
} else {
|
||||
versionToLaunch = baseVersion;
|
||||
// Определяем ID версии в зависимости от типа загрузчика
|
||||
if (loaderType === 'neoforge' && neoForgeVersion) {
|
||||
// NeoForge создает версию с ID "neoforge-{version}"
|
||||
const neoForgeId = `neoforge-${neoForgeVersion}`;
|
||||
|
||||
// Проверяем, существует ли такая версия
|
||||
if (versionsContents.includes(neoForgeId)) {
|
||||
versionToLaunch = neoForgeId;
|
||||
} else {
|
||||
// Если не существует, пробуем комбинированный ID для совместимости
|
||||
const combinedId = `${effectiveBaseVersion}-neoforge${neoForgeVersion}`;
|
||||
versionToLaunch = combinedId;
|
||||
|
||||
// Логируем для отладки
|
||||
console.log(
|
||||
'NeoForge версия не найдена, используем комбинированный ID:',
|
||||
combinedId,
|
||||
);
|
||||
}
|
||||
} else if (fabricVersion) {
|
||||
// Fabric создает версию с ID "{minecraftVersion}-fabric{fabricVersion}"
|
||||
const fabricId = `${effectiveBaseVersion}-fabric${fabricVersion}`;
|
||||
if (versionsContents.includes(fabricId)) {
|
||||
versionToLaunch = fabricId;
|
||||
} else {
|
||||
versionToLaunch = fabricId;
|
||||
}
|
||||
} else {
|
||||
versionToLaunch = effectiveBaseVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Запускаем версию:', versionToLaunch);
|
||||
console.log('Конфигурация:', {
|
||||
loaderType,
|
||||
neoForgeVersion,
|
||||
fabricVersion,
|
||||
effectiveBaseVersion,
|
||||
versionToLaunch,
|
||||
versionsContents,
|
||||
});
|
||||
|
||||
// Находим путь к Java
|
||||
// --- Поиск Java ---
|
||||
event.sender.send('installation-status', {
|
||||
step: 'java',
|
||||
message: 'Поиск Java...',
|
||||
@ -524,22 +640,24 @@ export function initMinecraftHandlers() {
|
||||
|
||||
console.log('Поиск Java...');
|
||||
|
||||
let javaPath;
|
||||
let javaPath = 'java';
|
||||
try {
|
||||
javaPath = await findJava();
|
||||
} catch (error) {
|
||||
console.warn('Ошибка при поиске Java:', error);
|
||||
event.sender.send('installation-status', {
|
||||
step: 'java-error',
|
||||
message: 'Не удалось найти Java. Используем системную Java.',
|
||||
message:
|
||||
'Не удалось найти Java. Попробуем запустить с системной Java.',
|
||||
});
|
||||
javaPath = 'java';
|
||||
}
|
||||
|
||||
// Далее пробуем установить Minecraft, но продолжаем даже при ошибках
|
||||
let resolvedVersion;
|
||||
console.log('Используем Java:', javaPath);
|
||||
|
||||
// --- 1. Установка ванильного Minecraft (effectiveBaseVersion) ---
|
||||
let resolvedVersion: any;
|
||||
|
||||
try {
|
||||
// 1. Получаем список версий и устанавливаем ванильный Minecraft
|
||||
event.sender.send('installation-status', {
|
||||
step: 'minecraft-list',
|
||||
message: 'Получение списка версий Minecraft...',
|
||||
@ -549,192 +667,210 @@ export function initMinecraftHandlers() {
|
||||
|
||||
const versionList = await getVersionList();
|
||||
const minecraftVersion = versionList.versions.find(
|
||||
(v) => v.id === baseVersion,
|
||||
(v) => v.id === effectiveBaseVersion,
|
||||
);
|
||||
|
||||
console.log('minecraftVersion:', minecraftVersion);
|
||||
|
||||
if (minecraftVersion) {
|
||||
// Устанавливаем базовую версию Minecraft
|
||||
const installMcTask = installTask(minecraftVersion, minecraftDir, {
|
||||
skipRevalidate: true,
|
||||
assetsDownloadConcurrency: 2,
|
||||
librariesDownloadConcurrency: 2,
|
||||
dispatcher: agent,
|
||||
});
|
||||
|
||||
console.log('installMcTask started for', minecraftVersion.id);
|
||||
|
||||
event.sender.send('installation-status', {
|
||||
step: 'minecraft-install',
|
||||
message: `Установка Minecraft ${baseVersion}...`,
|
||||
message: `Установка Minecraft ${minecraftVersion.id}...`,
|
||||
});
|
||||
|
||||
console.log('Установка Minecraft...');
|
||||
await installMcTask.startAndWait({
|
||||
onUpdate(task, chunkSize) {
|
||||
// локальный прогресс инсталлятора XMCL
|
||||
const local =
|
||||
installMcTask.total > 0
|
||||
? installMcTask.progress / installMcTask.total
|
||||
: 0;
|
||||
|
||||
try {
|
||||
const installMcTask = installTask(minecraftVersion, minecraftDir, {
|
||||
skipRevalidate: true,
|
||||
});
|
||||
const global = getGlobalProgress('minecraft-install', local);
|
||||
event.sender.send('overall-progress', global);
|
||||
},
|
||||
onFailed(task, error) {
|
||||
const stepName = (task as any).path || task.name || 'unknown';
|
||||
console.warn(
|
||||
`[minecraft-install] step "${stepName}" failed: ${
|
||||
(error as any).code ?? ''
|
||||
} ${(error as any).message}`,
|
||||
);
|
||||
|
||||
console.log('installMcTask:', installMcTask);
|
||||
|
||||
await installMcTask.startAndWait({
|
||||
onStart(task) {
|
||||
event.sender.send('installation-status', {
|
||||
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);
|
||||
event.sender.send('installation-status', {
|
||||
step: `minecraft-install.${task.path}`,
|
||||
message: `Прогресс ${task.name || task.path}: ${percentage}% (${installMcTask.progress}/${installMcTask.total})`,
|
||||
});
|
||||
},
|
||||
onFailed(task, error) {
|
||||
console.log('onFailed:', task, error);
|
||||
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.log('Ошибка при установке Minecraft, продолжаем:', error);
|
||||
}
|
||||
|
||||
// 2. Устанавливаем Fabric
|
||||
console.log('Попытка установки Fabric:', {
|
||||
minecraftVersion: baseVersion,
|
||||
fabricVersion: fabricVersion,
|
||||
minecraftDir: minecraftDir,
|
||||
event.sender.send('installation-status', {
|
||||
step: `minecraft-install.${stepName}`,
|
||||
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;
|
||||
|
||||
console.log(
|
||||
'Ошибка при установке ванильного Minecraft, продолжаем:',
|
||||
agg?.message || String(agg),
|
||||
innerCount ? `(внутренних ошибок: ${innerCount})` : '',
|
||||
);
|
||||
}
|
||||
|
||||
// --- 2. Установка Fabric (только для модпаков) ---
|
||||
if (!isVanillaVersion) {
|
||||
if (loaderType === 'neoforge' && neoForgeVersion) {
|
||||
try {
|
||||
event.sender.send('installation-status', {
|
||||
step: 'fabric-list',
|
||||
message: 'Получение списка версий Fabric...',
|
||||
step: 'neoforge-install',
|
||||
message: `Установка NeoForge ${neoForgeVersion}...`,
|
||||
});
|
||||
|
||||
if (fabricVersion) {
|
||||
event.sender.send('installation-status', {
|
||||
step: 'fabric-install',
|
||||
message: `Установка Fabric ${fabricVersion}...`,
|
||||
});
|
||||
event.sender.send(
|
||||
'overall-progress',
|
||||
getGlobalProgress('fabric-install', 0),
|
||||
);
|
||||
|
||||
console.log('installFabric:', {
|
||||
minecraftVersion: baseVersion,
|
||||
fabricVersion: fabricVersion,
|
||||
minecraftDir: minecraftDir,
|
||||
});
|
||||
console.log('installNeoForged:', {
|
||||
project: 'neoforge',
|
||||
version: neoForgeVersion,
|
||||
minecraftVersion: effectiveBaseVersion,
|
||||
minecraftDir,
|
||||
});
|
||||
|
||||
await installFabric({
|
||||
minecraftVersion: baseVersion,
|
||||
version: fabricVersion, // Используйте напрямую, без .version
|
||||
minecraft: minecraftDir,
|
||||
});
|
||||
}
|
||||
// Установка NeoForge
|
||||
await installNeoForged('neoforge', neoForgeVersion, minecraftDir, {
|
||||
minecraft: effectiveBaseVersion,
|
||||
java: javaPath,
|
||||
side: 'client',
|
||||
});
|
||||
|
||||
console.log('NeoForge установлен успешно!');
|
||||
|
||||
event.sender.send(
|
||||
'overall-progress',
|
||||
getGlobalProgress('fabric-install', 1),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при установке NeoForge:', error);
|
||||
event.sender.send('installation-status', {
|
||||
step: 'neoforge-error',
|
||||
message: `Ошибка установки NeoForge: ${error.message}`,
|
||||
});
|
||||
}
|
||||
} else if (fabricVersion) {
|
||||
// Существующий код для Fabric
|
||||
try {
|
||||
event.sender.send('installation-status', {
|
||||
step: 'fabric-install',
|
||||
message: `Установка Fabric ${fabricVersion}...`,
|
||||
});
|
||||
|
||||
event.sender.send(
|
||||
'overall-progress',
|
||||
getGlobalProgress('fabric-install', 0),
|
||||
);
|
||||
|
||||
console.log('installFabric:', {
|
||||
minecraftVersion: effectiveBaseVersion,
|
||||
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. Подготовка версии и установка зависимостей
|
||||
try {
|
||||
// Используем идентификатор Fabric-версии
|
||||
const fabricVersionId = `${baseVersion}-fabric${fabricVersion}`;
|
||||
// --- 3. Установка зависимостей для versionToLaunch ---
|
||||
try {
|
||||
if (!versionToLaunch) {
|
||||
throw new Error('versionToLaunch не определён');
|
||||
}
|
||||
|
||||
console.log('version-parse:', fabricVersionId);
|
||||
console.log('version-parse:', {
|
||||
minecraftDir,
|
||||
versionToLaunch,
|
||||
});
|
||||
|
||||
event.sender.send('installation-status', {
|
||||
step: 'version-parse',
|
||||
message: 'Подготовка версии...',
|
||||
});
|
||||
event.sender.send('installation-status', {
|
||||
step: 'version-parse',
|
||||
message: 'Подготовка версии...',
|
||||
});
|
||||
|
||||
console.log('version-parse:', {
|
||||
minecraftDir: minecraftDir,
|
||||
fabricVersionId: fabricVersionId,
|
||||
});
|
||||
resolvedVersion = await Version.parse(minecraftDir, versionToLaunch);
|
||||
|
||||
resolvedVersion = await Version.parse(
|
||||
minecraftDir,
|
||||
fabricVersionId,
|
||||
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', {
|
||||
step: 'dependencies',
|
||||
message: 'Установка библиотек и ресурсов...',
|
||||
step: `dependencies.${stepName}`,
|
||||
message: `Ошибка: ${(error as any).message}`,
|
||||
});
|
||||
|
||||
const depsTask = installDependenciesTask(resolvedVersion, {
|
||||
assetsDownloadConcurrency: 4,
|
||||
skipRevalidate: true,
|
||||
prevalidSizeOnly: true,
|
||||
checksumValidatorResolver: (checksum) => ({
|
||||
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.log('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.log(
|
||||
'Ошибка при загрузке ресурсов, продолжаем запуск:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Ошибка при подготовке версии, продолжаем:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Произошла ошибка при подготовке Minecraft:', error);
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.log(
|
||||
'Ошибка при подготовке версии/зависимостей, продолжаем запуск:',
|
||||
error.message || error,
|
||||
);
|
||||
}
|
||||
|
||||
// Загрузка и проверка authlib-injector
|
||||
const authlibPath = await ensureAuthlibInjectorExists(appPath);
|
||||
// --- authlib-injector ---
|
||||
const authlibPath = await ensureAuthlibInjectorExists(userDataPath);
|
||||
console.log('authlibPath:', authlibPath);
|
||||
|
||||
event.sender.send('installation-status', {
|
||||
@ -742,7 +878,7 @@ export function initMinecraftHandlers() {
|
||||
message: 'authlib-injector готов',
|
||||
});
|
||||
|
||||
// Запускаем Minecraft с authlib-injector для Ely.by
|
||||
// --- Запуск игры ---
|
||||
console.log('Запуск игры...');
|
||||
|
||||
event.sender.send('installation-status', {
|
||||
@ -750,32 +886,19 @@ export function initMinecraftHandlers() {
|
||||
message: 'Запуск игры...',
|
||||
});
|
||||
|
||||
console.log('Запуск игры...');
|
||||
|
||||
// При запуске используем переданные параметры
|
||||
const packDir = path.join(versionsDir, packName);
|
||||
|
||||
// При формировании конфигурации запуска создаем объект server только с нужными параметрами
|
||||
const serverConfig: any = { ip: serverIp };
|
||||
|
||||
// Добавляем порт только если он был передан
|
||||
if (serverPort) {
|
||||
serverConfig.port = serverPort;
|
||||
}
|
||||
|
||||
console.log('packDir:', packDir);
|
||||
event.sender.send('overall-progress', getGlobalProgress('launch', 0));
|
||||
|
||||
const proc = await launch({
|
||||
gamePath: packDir,
|
||||
resourcePath: minecraftDir,
|
||||
javaPath,
|
||||
version: versionToLaunch,
|
||||
version: versionToLaunch!,
|
||||
launcherName: 'popa-popa',
|
||||
extraJVMArgs: [
|
||||
'-Dlog4j2.formatMsgNoLookups=true',
|
||||
`-javaagent:${authlibPath}=${API_BASE_URL}`,
|
||||
`-Xmx${memory}M`,
|
||||
'-Dauthlibinjector.skinWhitelist=127.0.0.1,falrfg-213-87-196-173.ru.tuna.am',
|
||||
'-Dauthlibinjector.skinWhitelist=https://minecraft.api.popa-popa.ru/',
|
||||
'-Dauthlibinjector.debug=verbose,authlib',
|
||||
'-Dauthlibinjector.legacySkinPolyfill=enabled',
|
||||
'-Dauthlibinjector.mojangAntiFeatures=disabled',
|
||||
@ -785,7 +908,6 @@ export function initMinecraftHandlers() {
|
||||
'--quickPlayMultiplayer',
|
||||
`${serverIp}:${serverPort || 25565}`,
|
||||
],
|
||||
// Используем данные аутентификации Yggdrasil
|
||||
accessToken,
|
||||
gameProfile: {
|
||||
id: uuid,
|
||||
@ -793,33 +915,91 @@ 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) => {
|
||||
console.log(`Minecraft stdout: ${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 };
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка при запуске Minecraft:', error);
|
||||
event.sender.send('installation-status', {
|
||||
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) };
|
||||
}
|
||||
});
|
||||
|
||||
// Добавьте в функцию initMinecraftHandlers или создайте новую
|
||||
ipcMain.handle('get-pack-files', async (event, packName) => {
|
||||
try {
|
||||
const appPath = path.dirname(app.getPath('exe'));
|
||||
const minecraftDir = path.join(appPath, '.minecraft');
|
||||
const userDataPath = app.getPath('userData');
|
||||
const minecraftDir = path.join(app.getPath('userData'), 'minecraft');
|
||||
const packDir = path.join(minecraftDir, 'versions', packName);
|
||||
|
||||
if (!fs.existsSync(packDir)) {
|
||||
@ -862,8 +1042,8 @@ export function initMinecraftHandlers() {
|
||||
// Сначала создаем общую функцию для получения установленных версий
|
||||
function getInstalledVersions() {
|
||||
try {
|
||||
const appPath = path.dirname(app.getPath('exe'));
|
||||
const minecraftDir = path.join(appPath, '.minecraft');
|
||||
const userDataPath = app.getPath('userData');
|
||||
const minecraftDir = path.join(app.getPath('userData'), 'minecraft');
|
||||
const versionsDir = path.join(minecraftDir, 'versions');
|
||||
|
||||
if (!fs.existsSync(versionsDir)) {
|
||||
@ -875,28 +1055,37 @@ export function initMinecraftHandlers() {
|
||||
|
||||
for (const item of items) {
|
||||
const versionPath = path.join(versionsDir, item);
|
||||
if (fs.statSync(versionPath).isDirectory()) {
|
||||
// Проверяем, есть ли конфигурация для пакета
|
||||
const versionJsonPath = path.join(versionPath, `${item}.json`);
|
||||
let versionInfo = {
|
||||
id: item,
|
||||
name: item,
|
||||
version: item,
|
||||
};
|
||||
if (!fs.statSync(versionPath).isDirectory()) continue;
|
||||
|
||||
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);
|
||||
// ❗ Прячем технические версии загрузчиков
|
||||
if (item.includes('-fabric') || item.includes('neoforge')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(versionPath);
|
||||
const jsonFile = files.find((f) => f.endsWith('.json'));
|
||||
|
||||
if (!jsonFile) continue;
|
||||
|
||||
const versionJsonPath = path.join(versionPath, jsonFile);
|
||||
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 };
|
||||
@ -1054,8 +1243,8 @@ export function initPackConfigHandlers() {
|
||||
// Обработчик для сохранения настроек сборки
|
||||
ipcMain.handle('save-pack-config', async (event, { packName, config }) => {
|
||||
try {
|
||||
const appPath = path.dirname(app.getPath('exe'));
|
||||
const minecraftDir = path.join(appPath, '.minecraft');
|
||||
const userDataPath = app.getPath('userData');
|
||||
const minecraftDir = path.join(app.getPath('userData'), 'minecraft');
|
||||
const packDir = path.join(minecraftDir, 'versions', packName);
|
||||
|
||||
// Создаем папку для сборки, если она не существует
|
||||
@ -1085,8 +1274,8 @@ export function initPackConfigHandlers() {
|
||||
// Обработчик для загрузки настроек сборки
|
||||
ipcMain.handle('load-pack-config', async (event, { packName }) => {
|
||||
try {
|
||||
const appPath = path.dirname(app.getPath('exe'));
|
||||
const minecraftDir = path.join(appPath, '.minecraft');
|
||||
const userDataPath = app.getPath('userData');
|
||||
const minecraftDir = path.join(app.getPath('userData'), 'minecraft');
|
||||
const packDir = path.join(minecraftDir, 'versions', packName);
|
||||
const configPath = path.join(packDir, CONFIG_FILENAME);
|
||||
|
||||
@ -1130,8 +1319,8 @@ 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 userDataPath = app.getPath('userData');
|
||||
const minecraftDir = path.join(app.getPath('userData'), 'minecraft');
|
||||
const versionsDir = path.join(minecraftDir, 'versions');
|
||||
const versionPath = path.join(versionsDir, versionId);
|
||||
|
||||
@ -1150,9 +1339,11 @@ ipcMain.handle('get-version-config', async (event, { versionId }) => {
|
||||
versionFileName: `${versionId}_version.txt`,
|
||||
packName: versionId,
|
||||
memory: 4096,
|
||||
baseVersion: '1.21.4',
|
||||
baseVersion: '1.21.10',
|
||||
serverIp: 'popa-popa.ru',
|
||||
fabricVersion: '0.16.14',
|
||||
fabricVersion: null,
|
||||
neoForgeVersion: null,
|
||||
loaderType: 'fabric', // 'fabric', 'neoforge', 'vanilla'
|
||||
preserveFiles: ['popa-launcher-config.json'],
|
||||
};
|
||||
|
||||
@ -1162,6 +1353,22 @@ ipcMain.handle('get-version-config', async (event, { versionId }) => {
|
||||
'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';
|
||||
config.loaderType = 'fabric';
|
||||
}
|
||||
|
||||
// Если это NeoForge сборка, добавьте соответствующие настройки
|
||||
if (versionId === 'MyNeoForgePack') {
|
||||
config.downloadUrl =
|
||||
'https://github.com/YOUR_USERNAME/YOUR_NEOFORGE_PACK/releases/latest/download/MyNeoForgePack.zip';
|
||||
config.apiReleaseUrl =
|
||||
'https://api.github.com/repos/YOUR_USERNAME/YOUR_NEOFORGE_PACK/releases/latest';
|
||||
config.baseVersion = '1.21.1';
|
||||
config.fabricVersion = null;
|
||||
config.neoForgeVersion = '20.6.2';
|
||||
config.loaderType = 'neoforge';
|
||||
config.memory = 6144; // NeoForge может требовать больше памяти
|
||||
}
|
||||
|
||||
// Если есть конфигурационный файл, загружаем из него
|
||||
|
||||
@ -13,7 +13,17 @@ export type Channels =
|
||||
| 'update-available'
|
||||
| 'install-update'
|
||||
| 'get-installed-versions'
|
||||
| 'get-available-versions';
|
||||
| 'get-available-versions'
|
||||
| 'minecraft-log'
|
||||
| 'minecraft-error'
|
||||
| 'overall-progress'
|
||||
| 'stop-minecraft'
|
||||
| 'minecraft-started'
|
||||
| 'apply-launcher-settings'
|
||||
| 'tray-navigate'
|
||||
| 'tray-logout'
|
||||
| 'auth-changed'
|
||||
| 'minecraft-stopped';
|
||||
|
||||
const electronHandler = {
|
||||
ipcRenderer: {
|
||||
@ -35,6 +45,9 @@ const electronHandler = {
|
||||
invoke(channel: Channels, ...args: unknown[]): Promise<any> {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
},
|
||||
removeAllListeners(channel: Channels) {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -12,18 +12,64 @@
|
||||
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 {
|
||||
position: relative;
|
||||
color: white;
|
||||
height: 100vh;
|
||||
background: linear-gradient(242.94deg, #000000 39.07%, #3b4187 184.73%);
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
p {
|
||||
@ -57,3 +103,40 @@ h6 {
|
||||
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%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,19 @@ import Profile from './pages/Profile';
|
||||
import Shop from './pages/Shop';
|
||||
import Marketplace from './pages/Marketplace';
|
||||
import { Registration } from './pages/Registration';
|
||||
import { FullScreenLoader } from './components/FullScreenLoader';
|
||||
import { News } from './pages/News';
|
||||
import PageHeader from './components/PageHeader';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import DailyReward from './pages/DailyReward';
|
||||
import DailyQuests from './pages/DailyQuests';
|
||||
import Settings from './pages/Settings';
|
||||
import Inventory from './pages/Inventory';
|
||||
import FakePaymentPage from './pages/FakePaymentPage';
|
||||
import { TrayBridge } from './utils/TrayBridge';
|
||||
import { API_BASE_URL } from './api';
|
||||
import { PromoRedeem } from './pages/PromoRedeem';
|
||||
import VoicePage from './pages/VoicePage';
|
||||
|
||||
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
@ -26,18 +39,53 @@ const AuthCheck = ({ children }: { children: ReactNode }) => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
if (savedConfig) {
|
||||
const config = JSON.parse(savedConfig);
|
||||
if (config.accessToken) {
|
||||
// Можно добавить дополнительную проверку токена
|
||||
const isValid = await validateToken(
|
||||
config.accessToken,
|
||||
config.clientToken,
|
||||
);
|
||||
setIsAuthenticated(isValid);
|
||||
return;
|
||||
}
|
||||
if (!savedConfig) {
|
||||
setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = JSON.parse(savedConfig);
|
||||
|
||||
if (config.accessToken && config.clientToken) {
|
||||
// 1. Проверяем валидность токена через ваш API
|
||||
const response = await fetch(`${API_BASE_URL}/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accessToken: config.accessToken,
|
||||
clientToken: config.clientToken,
|
||||
}),
|
||||
});
|
||||
|
||||
const isValid = response.ok;
|
||||
|
||||
// 2. ДОПОЛНИТЕЛЬНО: проверяем доступ к API через /auth/me
|
||||
if (isValid) {
|
||||
try {
|
||||
const meResponse = await fetch(
|
||||
`${API_BASE_URL}/auth/me?${new URLSearchParams({
|
||||
accessToken: config.accessToken,
|
||||
clientToken: config.clientToken,
|
||||
})}`,
|
||||
);
|
||||
|
||||
if (!meResponse.ok) {
|
||||
// Токен валиден для Yggdrasil, но нет доступа к API
|
||||
console.warn('Токен валиден, но нет доступа к API лаунчера');
|
||||
setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки доступа к API:', error);
|
||||
setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsAuthenticated(isValid);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthenticated(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки авторизации:', error);
|
||||
@ -86,18 +134,116 @@ const AuthCheck = ({ children }: { children: ReactNode }) => {
|
||||
};
|
||||
|
||||
if (isAuthenticated === null) {
|
||||
return <div>Loading...</div>;
|
||||
return <FullScreenLoader message="Проверка авторизации..." />;
|
||||
}
|
||||
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const getInitialRoute = () => {
|
||||
try {
|
||||
const settingsRaw = localStorage.getItem('launcher_settings');
|
||||
const settings = settingsRaw ? JSON.parse(settingsRaw) : null;
|
||||
|
||||
if (!settings?.rememberLastRoute) return ['/'];
|
||||
|
||||
const saved = localStorage.getItem('last_route');
|
||||
return [saved || '/'];
|
||||
} catch {
|
||||
return ['/'];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Router initialEntries={getInitialRoute()}>
|
||||
<AppLayout />
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
const AppLayout = () => {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const settingsRaw = localStorage.getItem('launcher_settings');
|
||||
const settings = settingsRaw ? JSON.parse(settingsRaw) : null;
|
||||
if (!settings?.rememberLastRoute) return;
|
||||
|
||||
localStorage.setItem('last_route', location.pathname);
|
||||
} catch {}
|
||||
}, [location.pathname]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const pushLauncherSettingsToMain = async () => {
|
||||
try {
|
||||
const raw = localStorage.getItem('launcher_settings');
|
||||
const s = raw ? JSON.parse(raw) : null;
|
||||
|
||||
await window.electron.ipcRenderer.invoke('apply-launcher-settings', {
|
||||
autoLaunch: Boolean(s?.autoLaunch),
|
||||
startInTray: Boolean(s?.startInTray),
|
||||
closeToTray: s?.closeToTray !== false,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to push launcher settings to main', e);
|
||||
}
|
||||
};
|
||||
|
||||
// применяем при загрузке
|
||||
applySettings();
|
||||
pushLauncherSettingsToMain();
|
||||
|
||||
// применяем после сохранения настроек
|
||||
window.addEventListener('settings-updated', applySettings);
|
||||
window.addEventListener('settings-updated', pushLauncherSettingsToMain);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('settings-updated', applySettings);
|
||||
window.removeEventListener(
|
||||
'settings-updated',
|
||||
pushLauncherSettingsToMain,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Просто используйте window.open без useNavigate
|
||||
const handleRegister = () => {
|
||||
window.open('https://account.ely.by/register', '_blank');
|
||||
};
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const path = location.pathname;
|
||||
|
||||
useEffect(() => {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
@ -109,69 +255,180 @@ const App = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem('launcher_config');
|
||||
const isAuthed = !!raw && !!JSON.parse(raw).accessToken;
|
||||
|
||||
window.electron.ipcRenderer.invoke('auth-changed', { isAuthed });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<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={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflowY: 'hidden',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
<MinecraftBackground />
|
||||
<TopBar onRegister={handleRegister} username={username || ''} />
|
||||
<Notifier />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/registration" element={<Registration />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<VersionsExplorer />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/launch/:versionId"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<LaunchPage />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<Profile />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/shop"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<Shop />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/marketplace"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<Marketplace />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<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 />
|
||||
<TrayBridge />
|
||||
|
||||
<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="/inventory"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<Inventory />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/daily"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<DailyReward />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dailyquests"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<DailyQuests />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<Settings />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/voice"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<VoicePage />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/shop"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<Shop />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/marketplace"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<Marketplace />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/news"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<News />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/fakepaymentpage"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<FakePaymentPage />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/promocode"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<PromoRedeem />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
</Router>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,95 @@
|
||||
export const API_BASE_URL = 'https://minecraft.api.popa-popa.ru';
|
||||
|
||||
// АВТОРИЗАЦИЯ \\
|
||||
|
||||
export interface AuthSession {
|
||||
accessToken: string;
|
||||
clientToken: string;
|
||||
selectedProfile: {
|
||||
id: string; // UUID пользователя
|
||||
name: string; // Имя игрока
|
||||
};
|
||||
user?: {
|
||||
id: string;
|
||||
properties?: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function authenticate(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<AuthSession> {
|
||||
const clientToken = Math.random().toString(36).substring(2);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/auth/authenticate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
clientToken,
|
||||
requestUser: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Ошибка авторизации: ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Валидация токена
|
||||
export async function validateToken(
|
||||
accessToken: string,
|
||||
clientToken: string,
|
||||
): Promise<boolean> {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accessToken, clientToken }),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
// Обновление токена
|
||||
export async function refreshToken(
|
||||
accessToken: string,
|
||||
clientToken: string,
|
||||
): Promise<AuthSession | null> {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accessToken, clientToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Получение информации о себе (проверяет, что токен валидный И есть доступ к API)
|
||||
export async function getMe(): Promise<MeResponse> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
if (!accessToken || !clientToken) {
|
||||
throw new Error('Нет токенов авторизации');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ accessToken, clientToken });
|
||||
const response = await fetch(`${API_BASE_URL}/auth/me?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось получить данные пользователя');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
uuid: string;
|
||||
username: string;
|
||||
@ -75,26 +165,7 @@ export interface OnlinePlayersResponse {
|
||||
}
|
||||
|
||||
export interface MarketplaceResponse {
|
||||
items: [
|
||||
{
|
||||
_id: string;
|
||||
id: string;
|
||||
material: string;
|
||||
amount: number;
|
||||
price: number;
|
||||
seller_name: string;
|
||||
server_ip: string;
|
||||
display_name: string | null;
|
||||
lore: string | null;
|
||||
enchants: string | null;
|
||||
item_data: {
|
||||
slot: number;
|
||||
material: string;
|
||||
amount: number;
|
||||
};
|
||||
created_at: string;
|
||||
},
|
||||
];
|
||||
items: MarketplaceItemResponse[];
|
||||
total: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
@ -103,6 +174,7 @@ export interface MarketplaceResponse {
|
||||
export interface MarketplaceItemResponse {
|
||||
_id: string;
|
||||
id: string;
|
||||
description: string;
|
||||
material: string;
|
||||
amount: number;
|
||||
price: number;
|
||||
@ -115,6 +187,10 @@ export interface MarketplaceItemResponse {
|
||||
slot: number;
|
||||
material: string;
|
||||
amount: number;
|
||||
meta?: {
|
||||
enchants?: Record<string, number>;
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
@ -183,6 +259,688 @@ export interface VerificationStatusResponse {
|
||||
is_verified: boolean;
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
markdown: string;
|
||||
preview?: string;
|
||||
tags?: string[];
|
||||
is_published?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateNewsPayload {
|
||||
title: string;
|
||||
preview?: string;
|
||||
markdown: string;
|
||||
is_published: boolean;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
username: string;
|
||||
uuid: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
// ===== БОНУСЫ / ПРОКАЧКА =====
|
||||
|
||||
export interface UserBonus {
|
||||
id: string;
|
||||
bonus_type_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
effect_type: string;
|
||||
effect_value: number;
|
||||
level: number;
|
||||
purchased_at: string;
|
||||
can_upgrade: boolean;
|
||||
upgrade_price: number;
|
||||
is_active: boolean;
|
||||
is_permanent: boolean;
|
||||
expires_at?: string;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export type UserBonusesResponse = {
|
||||
bonuses: UserBonus[];
|
||||
};
|
||||
|
||||
export interface BonusType {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
effect_type: string;
|
||||
base_effect_value: number;
|
||||
effect_increment: number;
|
||||
price: number;
|
||||
upgrade_price: number;
|
||||
duration: number;
|
||||
max_level: number;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export type BonusTypesResponse = {
|
||||
bonuses: BonusType[];
|
||||
};
|
||||
|
||||
export interface DailyQuest {
|
||||
key: string;
|
||||
title: string;
|
||||
event?: string;
|
||||
target?: string;
|
||||
required: number;
|
||||
progress: number;
|
||||
reward: number;
|
||||
status: 'active' | 'completed' | 'claimed';
|
||||
claimed_at?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export interface DailyQuestsStatusResponse {
|
||||
ok: boolean;
|
||||
day: string;
|
||||
was_online_today: boolean;
|
||||
seconds_to_next: number;
|
||||
next_reset_at_utc: string;
|
||||
next_reset_at_local: string;
|
||||
quests: DailyQuest[];
|
||||
}
|
||||
|
||||
export interface DailyQuestClaimResponse {
|
||||
claimed: boolean;
|
||||
coins_added?: number;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function fetchBonusTypes(): Promise<BonusType[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/bonuses/types`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось получить список прокачек');
|
||||
}
|
||||
|
||||
const data: BonusTypesResponse = await response.json();
|
||||
return data.bonuses || [];
|
||||
}
|
||||
|
||||
export async function fetchUserBonuses(username: string): Promise<UserBonus[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/bonuses/user/${username}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось получить бонусы игрока');
|
||||
}
|
||||
|
||||
const data: UserBonusesResponse = await response.json();
|
||||
return data.bonuses || [];
|
||||
}
|
||||
|
||||
export async function purchaseBonus(
|
||||
username: string,
|
||||
bonus_type_id: string,
|
||||
): Promise<{
|
||||
status: string;
|
||||
message: string;
|
||||
remaining_coins?: number;
|
||||
}> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/bonuses/purchase`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
bonus_type_id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось купить прокачку';
|
||||
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.message) msg = errorData.message;
|
||||
else if (Array.isArray(errorData.detail)) {
|
||||
msg = errorData.detail.map((d: any) => d.msg).join(', ');
|
||||
} else if (typeof errorData.detail === 'string') {
|
||||
msg = errorData.detail;
|
||||
}
|
||||
} catch {
|
||||
// оставляем дефолтное сообщение
|
||||
}
|
||||
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function upgradeBonus(
|
||||
username: string,
|
||||
bonus_id: string,
|
||||
): Promise<{
|
||||
status: string;
|
||||
message: string;
|
||||
bonus_id: string;
|
||||
new_level?: number;
|
||||
}> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/bonuses/upgrade`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
bonus_id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось улучшить бонус';
|
||||
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.message) msg = errorData.message;
|
||||
else if (Array.isArray(errorData.detail)) {
|
||||
msg = errorData.detail.map((d: any) => d.msg).join(', ');
|
||||
} else if (typeof errorData.detail === 'string') {
|
||||
msg = errorData.detail;
|
||||
}
|
||||
} catch {
|
||||
// оставляем дефолтное сообщение
|
||||
}
|
||||
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function toggleBonusActivation(
|
||||
username: string,
|
||||
bonus_id: string,
|
||||
): Promise<{
|
||||
status: string;
|
||||
message: string;
|
||||
bonus_id: string;
|
||||
is_active: boolean;
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/bonuses/toggle-activation`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
bonus_id,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось переключить бонус';
|
||||
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.message) msg = errorData.message;
|
||||
else if (Array.isArray(errorData.detail)) {
|
||||
msg = errorData.detail.map((d: any) => d.msg).join(', ');
|
||||
} else if (typeof errorData.detail === 'string') {
|
||||
msg = errorData.detail;
|
||||
}
|
||||
} catch {
|
||||
// оставляем дефолтное сообщение
|
||||
}
|
||||
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// ===== QR AUTH =====
|
||||
|
||||
export type QrInitResponse = {
|
||||
token: string;
|
||||
qr_url: string;
|
||||
expires_at: string; // ISO
|
||||
};
|
||||
|
||||
export type QrStatusPending = {
|
||||
status: 'pending' | 'approved' | 'expired' | 'consumed';
|
||||
};
|
||||
|
||||
export type QrStatusOk = {
|
||||
status: 'ok';
|
||||
accessToken: string;
|
||||
clientToken: string;
|
||||
selectedProfile: { id: string; name: string };
|
||||
};
|
||||
|
||||
export type QrStatusResponse = QrStatusPending | QrStatusOk;
|
||||
|
||||
export async function qrInit(device_id?: string): Promise<QrInitResponse> {
|
||||
const url = device_id
|
||||
? `${API_BASE_URL}/auth/qr/init?device_id=${encodeURIComponent(device_id)}`
|
||||
: `${API_BASE_URL}/auth/qr/init`;
|
||||
|
||||
const response = await fetch(url, { method: 'POST' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось создать QR-логин');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function qrStatus(
|
||||
token: string,
|
||||
device_id?: string,
|
||||
): Promise<QrStatusResponse> {
|
||||
const qs = new URLSearchParams({ token });
|
||||
if (device_id) qs.set('device_id', device_id);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/auth/qr/status?${qs.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось проверить статус QR-логина');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// ===== КЕЙСЫ =====
|
||||
|
||||
export interface CaseItemMeta {
|
||||
display_name?: string | null;
|
||||
lore?: string[] | null;
|
||||
enchants?: Record<string, number> | null;
|
||||
durability?: number | null;
|
||||
}
|
||||
|
||||
export interface CaseItem {
|
||||
id: string;
|
||||
name?: string;
|
||||
material: string;
|
||||
amount: number;
|
||||
weight?: number;
|
||||
meta?: CaseItemMeta;
|
||||
}
|
||||
|
||||
export interface Case {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
image_url?: string;
|
||||
server_ips?: string[];
|
||||
items_count?: number;
|
||||
items?: CaseItem[];
|
||||
}
|
||||
|
||||
export interface OpenCaseResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
operation_id: string;
|
||||
balance: number;
|
||||
reward: CaseItem;
|
||||
}
|
||||
|
||||
export async function fetchCases(): Promise<Case[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/cases/`);
|
||||
if (!response.ok) throw new Error('Не удалось получить список кейсов');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function fetchCase(case_id: string): Promise<Case> {
|
||||
const response = await fetch(`${API_BASE_URL}/cases/${case_id}`);
|
||||
if (!response.ok) throw new Error('Не удалось получить информацию о кейсе');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function openCase(
|
||||
case_id: string,
|
||||
username: string,
|
||||
server_ip: string,
|
||||
): Promise<OpenCaseResponse> {
|
||||
const url = new URL(`${API_BASE_URL}/cases/${case_id}/open`);
|
||||
url.searchParams.set('username', username);
|
||||
url.searchParams.set('server_ip', server_ip);
|
||||
|
||||
const response = await fetch(url.toString(), { method: 'POST' });
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось открыть кейс';
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
msg = errorData.message || errorData.detail || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// ===== КЕЙСЫ ===== \\
|
||||
|
||||
// ===== Инвентарь =====
|
||||
|
||||
export interface InventoryRawItem {
|
||||
_id: string;
|
||||
id: string; // item_id для withdraw
|
||||
username: string;
|
||||
server_ip: string;
|
||||
item_data: {
|
||||
material: string;
|
||||
amount: number;
|
||||
meta?: {
|
||||
display_name?: string | null;
|
||||
lore?: string[] | null;
|
||||
enchants?: Record<string, number> | null;
|
||||
durability?: number | null;
|
||||
};
|
||||
};
|
||||
source?: {
|
||||
type: string;
|
||||
case_id?: string;
|
||||
case_name?: string;
|
||||
};
|
||||
status: 'stored' | 'delivered' | string;
|
||||
created_at: string;
|
||||
delivered_at?: string | null;
|
||||
withdraw_operation_id?: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryItemsResponse {
|
||||
items: InventoryRawItem[];
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function fetchInventoryItems(
|
||||
username: string,
|
||||
server_ip: string,
|
||||
page = 1,
|
||||
limit = 28,
|
||||
): Promise<InventoryItemsResponse> {
|
||||
const url = new URL(`${API_BASE_URL}/inventory/items`);
|
||||
url.searchParams.set('username', username);
|
||||
url.searchParams.set('server_ip', server_ip);
|
||||
url.searchParams.set('page', String(page));
|
||||
url.searchParams.set('limit', String(limit));
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось получить инвентарь';
|
||||
try {
|
||||
const err = await response.json();
|
||||
msg = err.message || err.detail || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function withdrawInventoryItem(payload: {
|
||||
username: string;
|
||||
item_id: string;
|
||||
server_ip: string;
|
||||
}): Promise<{ status: string; message?: string }> {
|
||||
const response = await fetch(`${API_BASE_URL}/inventory/withdraw`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось выдать предмет';
|
||||
try {
|
||||
const err = await response.json();
|
||||
msg = err.message || err.detail || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// ===== ИНВЕНТАРЬ ===== \\
|
||||
|
||||
// ===== Ежедневная награда =====
|
||||
export interface DailyStatusResponse {
|
||||
ok: boolean;
|
||||
can_claim: boolean;
|
||||
seconds_to_next: number;
|
||||
was_online_today: boolean;
|
||||
next_claim_at_utc: string;
|
||||
next_claim_at_local: string;
|
||||
streak: number;
|
||||
}
|
||||
|
||||
export interface DailyClaimResponse {
|
||||
claimed: boolean;
|
||||
coins_added?: number;
|
||||
streak?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DailyDaysResponse {
|
||||
ok: boolean;
|
||||
days: string[]; // YYYY-MM-DD (по ЕКБ)
|
||||
count: number;
|
||||
}
|
||||
|
||||
export async function fetchDailyClaimDays(
|
||||
limit = 120,
|
||||
): Promise<DailyDaysResponse> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
if (!accessToken || !clientToken) {
|
||||
throw new Error('Нет токенов авторизации');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
accessToken,
|
||||
clientToken,
|
||||
limit: String(limit),
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/users/daily/days?${params.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось получить дни ежедневных наград');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function fetchDailyStatus(): Promise<DailyStatusResponse> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
if (!accessToken || !clientToken) {
|
||||
throw new Error('Нет токенов авторизации');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ accessToken, clientToken });
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/users/daily/status?${params.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось получить статус ежедневной награды');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function claimDaily(): Promise<DailyClaimResponse> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
if (!accessToken || !clientToken) {
|
||||
throw new Error('Нет токенов авторизации');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ accessToken, clientToken });
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/users/daily/claim?${params.toString()}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось забрать ежедневную награду';
|
||||
try {
|
||||
const err = await response.json();
|
||||
msg = err.message || err.detail || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// ===== Ежедневная награда ===== \\
|
||||
|
||||
// ===== Ежедневные квесты =====
|
||||
|
||||
export async function fetchDailyQuestsStatus(): Promise<DailyQuestsStatusResponse> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
if (!accessToken || !clientToken) {
|
||||
throw new Error('Нет токенов авторизации');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ accessToken, clientToken });
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/users/daily-quests/status?${params.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось получить статус ежедневных квестов');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function claimDailyQuest(
|
||||
questKey: string,
|
||||
): Promise<DailyQuestClaimResponse> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
if (!accessToken || !clientToken) {
|
||||
throw new Error('Нет токенов авторизации');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
accessToken,
|
||||
clientToken,
|
||||
quest_key: questKey,
|
||||
});
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/users/daily-quests/claim?${params.toString()}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось забрать награду за квест';
|
||||
try {
|
||||
const err = await response.json();
|
||||
msg = err.message || err.detail || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// ===== Ежедневные квесты ===== \\
|
||||
|
||||
export async function fetchMe(): Promise<MeResponse> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
if (!accessToken || !clientToken) {
|
||||
throw new Error('Нет токенов авторизации');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
accessToken,
|
||||
clientToken,
|
||||
});
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/auth/me?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось получить данные пользователя');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function createNews(payload: CreateNewsPayload) {
|
||||
const { accessToken, clientToken } = getAuthTokens(); // ← используем launcher_config
|
||||
|
||||
if (!accessToken || !clientToken) {
|
||||
throw new Error('Необходимо войти в лаунчер, чтобы публиковать новости');
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('accessToken', accessToken);
|
||||
formData.append('clientToken', clientToken);
|
||||
formData.append('title', payload.title);
|
||||
formData.append('markdown', payload.markdown);
|
||||
formData.append('preview', payload.preview || '');
|
||||
formData.append('is_published', String(payload.is_published));
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/news`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || 'Ошибка при создании новости');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function deleteNews(id: string): Promise<void> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
if (!accessToken || !clientToken) {
|
||||
throw new Error('Необходимо войти в лаунчер');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
accessToken,
|
||||
clientToken,
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/news/${id}?${params.toString()}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || 'Не удалось удалить новость');
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchNews(): Promise<NewsItem[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/news`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось получить новости');
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function getVerificationStatus(
|
||||
username: string,
|
||||
): Promise<VerificationStatusResponse> {
|
||||
@ -261,6 +1019,57 @@ export async function RequestPlayerInventory(
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// ===== Marketplace: мои лоты (изменить цену / снять с продажи) =====
|
||||
|
||||
export async function updateMarketplaceItemPrice(
|
||||
username: string,
|
||||
item_id: string,
|
||||
new_price: number,
|
||||
): Promise<{ status: string; message?: string }> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/marketplace/items/${encodeURIComponent(item_id)}/price`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, new_price }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось изменить цену';
|
||||
try {
|
||||
const err = await response.json();
|
||||
msg = err.message || err.detail || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function cancelMarketplaceItemSale(
|
||||
username: string,
|
||||
item_id: string,
|
||||
): Promise<{ status: string; message?: string }> {
|
||||
const url = new URL(
|
||||
`${API_BASE_URL}/api/marketplace/items/${encodeURIComponent(item_id)}`,
|
||||
);
|
||||
url.searchParams.set('username', username);
|
||||
|
||||
const response = await fetch(url.toString(), { method: 'DELETE' });
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось снять товар с продажи';
|
||||
try {
|
||||
const err = await response.json();
|
||||
msg = err.message || err.detail || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function buyItem(
|
||||
buyer_username: string,
|
||||
item_id: string,
|
||||
@ -346,6 +1155,7 @@ export async function sellItem(
|
||||
amount: number,
|
||||
price: number,
|
||||
server_ip: string,
|
||||
description: string,
|
||||
): Promise<{ status: string; operation_id: string }> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/marketplace/items/sell`, {
|
||||
method: 'POST',
|
||||
@ -358,6 +1168,7 @@ export async function sellItem(
|
||||
amount,
|
||||
price,
|
||||
server_ip,
|
||||
description,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -403,6 +1214,40 @@ export async function fetchMarketplace(
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function fetchMyMarketplaceItems(
|
||||
username: string,
|
||||
server_ip?: string | null,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<MarketplaceResponse> {
|
||||
// вместо /items/me используем /items/by-seller/{username}
|
||||
const url = new URL(`${API_BASE_URL}/api/marketplace/items/by-seller/${encodeURIComponent(username)}`);
|
||||
|
||||
if (server_ip) url.searchParams.set('server_ip', server_ip);
|
||||
url.searchParams.set('page', String(page));
|
||||
url.searchParams.set('limit', String(limit));
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
// если на бэке “нет предметов” возвращают 404 — можно трактовать как пустой список
|
||||
if (response.status === 404) {
|
||||
return { items: [], total: 0, page, pages: 1 };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось получить ваши товары';
|
||||
try {
|
||||
const err = await response.json();
|
||||
msg = err.message || err.detail || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// ===== Marketplace ===== \\
|
||||
|
||||
// Исправьте тип возвращаемого значения
|
||||
export async function fetchActiveServers(): Promise<Server[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/pranks/servers`);
|
||||
|
||||
60
src/renderer/api/commands.ts
Normal file
60
src/renderer/api/commands.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { API_BASE_URL } from '../api';
|
||||
|
||||
export interface PrankCommand {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
command_template: string;
|
||||
server_ids: string[]; // ["*"] или конкретные id
|
||||
targetDescription: string;
|
||||
globalDescription: string;
|
||||
material: string;
|
||||
}
|
||||
|
||||
export interface PrankServer {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
online_players: number;
|
||||
max_players: number;
|
||||
}
|
||||
|
||||
export const fetchPrankCommands = async (): Promise<PrankCommand[]> => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/pranks/commands`);
|
||||
if (!res.ok) throw new Error('Failed to load prank commands');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const fetchPrankServers = async (): Promise<PrankServer[]> => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/pranks/servers`);
|
||||
if (!res.ok) throw new Error('Failed to load prank servers');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const executePrank = async (
|
||||
username: string,
|
||||
commandId: string,
|
||||
targetPlayer: string,
|
||||
serverId: string,
|
||||
) => {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/pranks/execute?username=${username}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
command_id: commandId,
|
||||
target_player: targetPlayer,
|
||||
server_id: serverId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
};
|
||||
32
src/renderer/api/promocodes.ts
Normal file
32
src/renderer/api/promocodes.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { API_BASE_URL } from '../api';
|
||||
|
||||
export interface RedeemPromoResponse {
|
||||
code: string;
|
||||
reward_coins: number;
|
||||
new_balance: number;
|
||||
}
|
||||
|
||||
export async function redeemPromoCode(
|
||||
username: string,
|
||||
code: string,
|
||||
): Promise<RedeemPromoResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('username', username);
|
||||
formData.append('code', code);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/promo/redeem`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let msg = 'Не удалось активировать промокод';
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
msg = errorData.message || errorData.detail || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
62
src/renderer/api/voiceRooms.ts
Normal file
62
src/renderer/api/voiceRooms.ts
Normal file
@ -0,0 +1,62 @@
|
||||
const API_BASE = 'https://minecraft.api.popa-popa.ru';
|
||||
|
||||
export type ApiRoom = {
|
||||
id: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
owner: string;
|
||||
max_users: number;
|
||||
usernames: string[];
|
||||
users: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export async function fetchRooms(): Promise<ApiRoom[]> {
|
||||
const res = await fetch(`${API_BASE}/api/voice/rooms`);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchRoomDetails(roomId: string): Promise<RoomDetails> {
|
||||
const res = await fetch(`${API_BASE}/api/voice/rooms/${roomId}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createPublicRoom(
|
||||
name: string,
|
||||
owner: string,
|
||||
isPublic: boolean,
|
||||
) {
|
||||
const res = await fetch(`${API_BASE}/api/voice/rooms`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
public: isPublic,
|
||||
owner,
|
||||
}),
|
||||
});
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function joinPrivateRoom(code: string): Promise<ApiRoom> {
|
||||
const res = await fetch(`${API_BASE}/api/voice/rooms/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
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;
|
||||
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/Icons/popa-popa.png
Normal file
BIN
src/renderer/assets/Icons/popa-popa.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
18
src/renderer/assets/Icons/popa-popa.svg
Normal file
18
src/renderer/assets/Icons/popa-popa.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 80 KiB |
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.
312
src/renderer/components/BonusShopItem.tsx
Normal file
312
src/renderer/components/BonusShopItem.tsx
Normal file
@ -0,0 +1,312 @@
|
||||
// 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: '27.5vw',
|
||||
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)',
|
||||
borderColor: 'rgba(200, 33, 242, 0.35)',
|
||||
boxShadow: '0 1.2vw 3.2vw rgba(53, 3, 66, 0.75)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Градиентный свет сверху — как в ShopItem */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
// background:
|
||||
// 'radial-gradient(circle at top, rgba(242,113,33,0.25), transparent 60%)',
|
||||
background:
|
||||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.10), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.16), transparent 55%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{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;
|
||||
@ -1,18 +1,7 @@
|
||||
// src/renderer/components/CapeCard.tsx
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
Typography,
|
||||
CardActions,
|
||||
Button,
|
||||
Tooltip,
|
||||
Box,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
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;
|
||||
@ -31,97 +20,189 @@ export interface CapeCardProps {
|
||||
actionDisabled?: boolean;
|
||||
}
|
||||
|
||||
const GRADIENT =
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||
|
||||
export default function CapeCard({
|
||||
cape,
|
||||
mode,
|
||||
onAction,
|
||||
actionDisabled = false,
|
||||
}: CapeCardProps) {
|
||||
// Определяем текст и цвет кнопки в зависимости от режима
|
||||
const getActionButton = () => {
|
||||
if (mode === 'shop') {
|
||||
return {
|
||||
text: 'Купить',
|
||||
color: 'primary',
|
||||
};
|
||||
} else {
|
||||
// Профиль
|
||||
return cape.is_active
|
||||
? { text: 'Снять', color: 'error' }
|
||||
: { text: 'Надеть', color: 'success' };
|
||||
}
|
||||
};
|
||||
|
||||
const actionButton = getActionButton();
|
||||
|
||||
// В функции компонента добавьте нормализацию данных
|
||||
const capeId = cape.cape_id || cape.id || '';
|
||||
const capeName = cape.cape_name || cape.name || '';
|
||||
const 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 (
|
||||
<Tooltip arrow title={capeDescription}>
|
||||
<Card
|
||||
<CustomTooltip arrow title={capeDescription} placement="bottom">
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
width: 200,
|
||||
width: '16.5vw',
|
||||
borderRadius: '1.2vw',
|
||||
overflow: 'hidden',
|
||||
position: 'relative', // для позиционирования ценника
|
||||
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(200, 33, 242, 0.35)',
|
||||
boxShadow: '0 1.2vw 3.2vw rgba(53, 3, 66, 0.75)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Ценник для магазина */}
|
||||
{mode === 'shop' && cape.price !== undefined && (
|
||||
{/* градиентная полоска-акцент (как у твоих блоков) */}
|
||||
<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={`${cape.price} коинов`}
|
||||
label={topRightChip}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 2,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.7)',
|
||||
top: '0.8vw',
|
||||
right: '0.8vw',
|
||||
zIndex: 3,
|
||||
height: '1.55rem',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 900,
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
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)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={cape.image_url}
|
||||
alt={capeName}
|
||||
{/* preview */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
transform: 'scale(2.9) translateX(66px) translateY(32px)',
|
||||
imageRendering: 'pixelated',
|
||||
px: '1.1vw',
|
||||
pt: '1.0vw',
|
||||
pb: '0.7vw',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardContent sx={{ bgcolor: 'rgba(255, 255, 255, 0.05)', pt: '9vh' }}>
|
||||
<Typography sx={{ color: 'white' }}>{capeName}</Typography>
|
||||
</CardContent>
|
||||
|
||||
<CardActions sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color={actionButton.color as 'primary' | 'success' | 'error'}
|
||||
onClick={() => onAction(capeId)}
|
||||
disabled={actionDisabled}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '20px',
|
||||
p: '5px 25px',
|
||||
color: 'white',
|
||||
backgroundColor: 'rgb(0, 134, 0)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 134, 0, 0.5)',
|
||||
},
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderRadius: '1.0vw',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
maxHeight: '21vw',
|
||||
}}
|
||||
>
|
||||
{actionButton.text}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
{/* Здесь показываем ЛЕВУЮ половину текстуры (лицевую часть) */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '46.2vw',
|
||||
height: '39.2vw',
|
||||
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>
|
||||
);
|
||||
};
|
||||
260
src/renderer/components/CaseItemsDialog.tsx
Normal file
260
src/renderer/components/CaseItemsDialog.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import type { Case, CaseItem } from '../api';
|
||||
import { fetchCase } from '../api';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
|
||||
const CARD_BG =
|
||||
'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)';
|
||||
|
||||
const CardFacePaperSx = {
|
||||
borderRadius: '1.2vw',
|
||||
background: CARD_BG,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
|
||||
color: 'white',
|
||||
} as const;
|
||||
|
||||
const GLASS_PAPER_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',
|
||||
backdropFilter: 'blur(16px)',
|
||||
} as const;
|
||||
|
||||
function stripMinecraftColors(text?: string | null): string {
|
||||
if (!text) return '';
|
||||
return text.replace(/§[0-9A-FK-ORa-fk-or]/g, '');
|
||||
}
|
||||
|
||||
function getChancePercent(itemWeight: number, total: number) {
|
||||
if (!total || total <= 0) return 0;
|
||||
return (itemWeight / total) * 100;
|
||||
}
|
||||
|
||||
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)'; // gold
|
||||
case 'epic':
|
||||
return 'rgba(186, 85, 211, 1)'; // purple
|
||||
case 'rare':
|
||||
return 'rgba(65, 105, 225, 1)'; // blue
|
||||
default:
|
||||
return 'rgba(255, 255, 255, 0.75)';
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
caseId: string;
|
||||
caseName?: string;
|
||||
};
|
||||
|
||||
export default function CaseItemsDialog({ open, onClose, caseId, caseName }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [caseData, setCaseData] = useState<Case | null>(null);
|
||||
const items: CaseItem[] = useMemo(() => {
|
||||
const list = caseData?.items ?? [];
|
||||
return [...list].sort((a, b) => {
|
||||
const wa = a.weight ?? Infinity;
|
||||
const wb = b.weight ?? Infinity;
|
||||
return wa - wb; // 🔥 по возрастанию weight (легендарки сверху)
|
||||
});
|
||||
}, [caseData]);
|
||||
|
||||
const totalWeight = useMemo(() => {
|
||||
return items.reduce((sum, it) => sum + (Number(it.weight) || 0), 0);
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const full = await fetchCase(caseId);
|
||||
if (!cancelled) setCaseData(full);
|
||||
} catch (e) {
|
||||
console.error('Ошибка при загрузке предметов кейса:', e);
|
||||
if (!cancelled) setCaseData(null);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, caseId]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
fullWidth
|
||||
maxWidth="md"
|
||||
PaperProps={{
|
||||
sx: GLASS_PAPER_SX,
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
pr: 6, // место под крестик
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
Предметы кейса{caseName ? ` — ${caseName}` : ''}
|
||||
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
'&:hover': { transform: 'scale(1.05)', background: 'rgba(255,255,255,0.10)' },
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers sx={{ borderColor: 'rgba(255,255,255,0.10)' }}>
|
||||
{loading ? (
|
||||
<Box sx={{ py: 6, display: 'flex', justifyContent: 'center' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : !items.length ? (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.75)' }}>
|
||||
Предметы не найдены (или кейс временно недоступен).
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{items.map((it) => {
|
||||
const w = Number(it.weight) || 0;
|
||||
const chance = getChancePercent(w, totalWeight);
|
||||
const displayNameRaw = it.meta?.display_name ?? it.name ?? it.material ?? 'Предмет';
|
||||
const displayName = stripMinecraftColors(displayNameRaw);
|
||||
|
||||
const texture = it.material
|
||||
? `https://cdn.minecraft.popa-popa.ru/textures/${it.material.toLowerCase()}.png`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Grid item xs={3} key={it.id}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
...CardFacePaperSx,
|
||||
width: '12vw',
|
||||
height: '12vw',
|
||||
minWidth: 110,
|
||||
minHeight: 110,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* верхняя плашка (редкость) */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.7,
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
color: getRarityColor(it.weight),
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '0.75rem',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
</Box>
|
||||
|
||||
{/* иконка */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexGrow: 1 }}>
|
||||
{texture ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={texture}
|
||||
alt={displayName}
|
||||
draggable={false}
|
||||
sx={{
|
||||
width: '5vw',
|
||||
height: '5vw',
|
||||
minWidth: 40,
|
||||
minHeight: 40,
|
||||
objectFit: 'contain',
|
||||
imageRendering: 'pixelated',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.6)' }}>?</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* низ: шанс/вес/кол-во */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.9,
|
||||
borderTop: '1px solid rgba(255,255,255,0.08)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontFamily: 'Benzin-Bold' }}>
|
||||
{chance.toFixed(2)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
273
src/renderer/components/CoinsDisplay.tsx
Normal file
273
src/renderer/components/CoinsDisplay.tsx
Normal file
@ -0,0 +1,273 @@
|
||||
// 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;
|
||||
|
||||
onClick?: () => void;
|
||||
disableRefreshOnClick?: boolean;
|
||||
|
||||
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',
|
||||
|
||||
onClick,
|
||||
disableRefreshOnClick = false,
|
||||
|
||||
sx,
|
||||
}: CoinsDisplayProps) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [settingsVersion, setSettingsVersion] = useState(0);
|
||||
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 handleClick = () => {
|
||||
// 1) если передали внешний обработчик — выполняем его
|
||||
if (onClick) onClick();
|
||||
|
||||
// 2) опционально оставляем обновление баланса по клику
|
||||
if (!disableRefreshOnClick && username) fetchCoinsData();
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setSettingsVersion((v) => v + 1);
|
||||
window.addEventListener('settings-updated', handler as EventListener);
|
||||
return () =>
|
||||
window.removeEventListener('settings-updated', handler as EventListener);
|
||||
}, []);
|
||||
|
||||
const isTooltipDisabledBySettings = useMemo(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('launcher_settings');
|
||||
if (!raw) return false;
|
||||
const s = JSON.parse(raw);
|
||||
return Boolean(s?.disableToolTip);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [settingsVersion]);
|
||||
|
||||
const tooltipEnabled = showTooltip && !isTooltipDisabledBySettings;
|
||||
|
||||
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: onClick ? 'pointer' : tooltipEnabled ? 'help' : 'default',
|
||||
|
||||
// можно оставить лёгкий намёк на загрузку, но без "пульса" текста
|
||||
opacity: isLoading ? 0.85 : 1,
|
||||
transition: 'opacity 0.2s ease',
|
||||
|
||||
...sx,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
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 (tooltipEnabled) {
|
||||
return (
|
||||
<CustomTooltip
|
||||
title={tooltipText}
|
||||
arrow
|
||||
placement="bottom"
|
||||
TransitionProps={{ timeout: 300 }}
|
||||
>
|
||||
{coinsDisplay}
|
||||
</CustomTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return coinsDisplay;
|
||||
}
|
||||
152
src/renderer/components/CustomComponents/SettingCheckboxRow.tsx
Normal file
152
src/renderer/components/CustomComponents/SettingCheckboxRow.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const GRADIENT =
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||
|
||||
export default function SettingCheckboxRow({
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const toggle = () => {
|
||||
if (disabled) return;
|
||||
onChange(!checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={toggle}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(!checked);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1vw',
|
||||
p: '0.9vw',
|
||||
borderRadius: '1.1vw',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
userSelect: 'none',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
|
||||
background: checked
|
||||
? 'linear-gradient(120deg, rgba(242,113,33,0.12), rgba(233,64,205,0.10))'
|
||||
: 'rgba(255,255,255,0.04)',
|
||||
|
||||
border: checked
|
||||
? '1px solid rgba(233,64,205,0.45)'
|
||||
: '1px solid rgba(255,255,255,0.10)',
|
||||
|
||||
transition: 'all 0.18s ease',
|
||||
'&:hover': disabled
|
||||
? undefined
|
||||
: {
|
||||
background: checked
|
||||
? 'linear-gradient(120deg, rgba(242,113,33,0.15), rgba(233,64,205,0.12))'
|
||||
: 'rgba(255,255,255,0.06)',
|
||||
border: checked
|
||||
? '1px solid rgba(233,64,205,0.55)'
|
||||
: '1px solid rgba(255,255,255,0.14)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* text */}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
fontSize: '1.3vw',
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{description && (
|
||||
<Typography
|
||||
sx={{
|
||||
mt: '0.25vw',
|
||||
color: 'rgba(255,255,255,0.60)',
|
||||
fontWeight: 700,
|
||||
fontSize: '1vw',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* glass checkbox */}
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
width: '2.6vw',
|
||||
height: '2.6vw',
|
||||
borderRadius: '0.75vw',
|
||||
background: 'rgba(0,0,0,0.22)',
|
||||
border: checked
|
||||
? '1px solid rgba(233,64,205,0.55)'
|
||||
: '1px solid rgba(255,255,255,0.14)',
|
||||
boxShadow: checked
|
||||
? '0 0.9vw 2.2vw rgba(233,64,205,0.18)'
|
||||
: '0 0.9vw 2.2vw rgba(0,0,0,0.25)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* check */}
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '0.45vw',
|
||||
background: checked ? GRADIENT : 'rgba(255,255,255,0.08)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
transform: checked ? 'scale(1)' : 'scale(0.92)',
|
||||
transition: 'transform 0.16s ease',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
style={{
|
||||
opacity: checked ? 1 : 0,
|
||||
transition: 'opacity 0.14s ease',
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M20 6L9 17l-5-5"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -8,12 +8,12 @@ import {
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Collapse,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
@ -190,7 +190,7 @@ export default function FilesSelector({
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <CircularProgress />;
|
||||
return <FullScreenLoader fullScreen={false} />;
|
||||
}
|
||||
|
||||
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;
|
||||
79
src/renderer/components/HeadAvatar.tsx
Normal file
79
src/renderer/components/HeadAvatar.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
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);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
requestIdRef.current += 1;
|
||||
const requestId = requestIdRef.current;
|
||||
|
||||
const baseUrl = skinUrl?.trim() ? skinUrl : DEFAULT_SKIN;
|
||||
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 = () => {
|
||||
// ✅ игнорим старые onload
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
|
||||
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) => {
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
console.error('Не удалось загрузить скин для HeadAvatar:', e);
|
||||
};
|
||||
|
||||
return () => {
|
||||
// ✅ гарантированно “убиваем” обработчики старого запроса
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
}, [skinUrl, size, version]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
{...canvasProps}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 4,
|
||||
imageRendering: 'pixelated',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -1,4 +1,17 @@
|
||||
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 {
|
||||
config: {
|
||||
@ -10,6 +23,8 @@ interface AuthFormProps {
|
||||
}
|
||||
|
||||
const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@ -19,74 +34,104 @@ const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Логин</Typography>
|
||||
<TextField
|
||||
<GradientTextField
|
||||
label="Никнейм"
|
||||
required
|
||||
name="username"
|
||||
variant="outlined"
|
||||
value={config.username}
|
||||
onChange={handleInputChange}
|
||||
sx={{
|
||||
width: '100%',
|
||||
// '& .MuiFormLabel-root': {
|
||||
// color: 'white',
|
||||
// },
|
||||
'& .MuiInputBase-input': {
|
||||
color: 'white',
|
||||
},
|
||||
'& .MuiInput-underline:after': {
|
||||
borderBottomColor: '#B2BAC2',
|
||||
},
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'& fieldset': {
|
||||
borderColor: '#E0E3E7',
|
||||
color: 'white',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: '#B2BAC2',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: '#6F7E8C',
|
||||
},
|
||||
},
|
||||
mt: '2.5vw',
|
||||
mb: '0vw',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6">Пароль</Typography>
|
||||
<TextField
|
||||
<GradientTextField
|
||||
label="Пароль"
|
||||
required
|
||||
type="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
variant="outlined"
|
||||
value={config.password}
|
||||
onChange={handleInputChange}
|
||||
sx={{
|
||||
width: '100%',
|
||||
// '& .MuiFormLabel-root': {
|
||||
// color: 'white',
|
||||
// },
|
||||
'& .MuiInputBase-input': {
|
||||
color: 'white',
|
||||
},
|
||||
'& .MuiInput-underline:after': {
|
||||
borderBottomColor: '#B2BAC2',
|
||||
},
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'& fieldset': {
|
||||
borderColor: '#E0E3E7',
|
||||
color: 'white',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: '#B2BAC2',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: '#6F7E8C',
|
||||
},
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,25 +1,187 @@
|
||||
import { Slider } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { Box, Slider, Typography } from '@mui/material';
|
||||
|
||||
interface MemorySliderProps {
|
||||
memory: number;
|
||||
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 (
|
||||
<Slider
|
||||
name="memory"
|
||||
aria-label="Memory"
|
||||
defaultValue={4096}
|
||||
valueLabelDisplay="auto"
|
||||
shiftStep={1024}
|
||||
step={1024}
|
||||
marks
|
||||
min={1024}
|
||||
max={32628}
|
||||
value={memory}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
mb: '1.2vh',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
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} />;
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
127
src/renderer/components/Notifications/CustomTooltip.tsx
Normal file
127
src/renderer/components/Notifications/CustomTooltip.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Tooltip, { tooltipClasses, TooltipProps } from '@mui/material/Tooltip';
|
||||
|
||||
const STORAGE_KEY = 'launcher_settings';
|
||||
|
||||
function readDisableTooltip(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return false;
|
||||
const s = JSON.parse(raw);
|
||||
return Boolean(s?.disableToolTip);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const readTooltipPolicy = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem('launcher_settings');
|
||||
if (!raw) return { disableToolTip: false, allowEssentialTooltips: true };
|
||||
const s = JSON.parse(raw);
|
||||
return {
|
||||
disableToolTip: Boolean(s?.disableToolTip),
|
||||
allowEssentialTooltips: s?.allowEssentialTooltips !== false, // default true
|
||||
};
|
||||
} catch {
|
||||
return { disableToolTip: false, allowEssentialTooltips: true };
|
||||
}
|
||||
};
|
||||
|
||||
// ВАЖНО: styled-компонент отдельно, чтобы не пересоздавался на каждый рендер
|
||||
const StyledTooltip = styled(({ className, ...props }: TooltipProps) => (
|
||||
<Tooltip {...props} classes={{ popper: className }} />
|
||||
))(() => ({
|
||||
[`& .${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 type CustomTooltipProps = TooltipProps & {
|
||||
/**
|
||||
* Можно принудительно отключить тултип снаружи,
|
||||
* плюс учитывается настройка disableToolTip из launcher_settings
|
||||
*/
|
||||
essential?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function CustomTooltip(props: CustomTooltipProps) {
|
||||
const { essential = false, children, ...rest } = props;
|
||||
|
||||
const { disableToolTip, allowEssentialTooltips } = useMemo(
|
||||
() => readTooltipPolicy(),
|
||||
// важно: чтобы при "Save" пересчитывалось — ты уже диспатчишь settings-updated
|
||||
// поэтому ниже мы просто прочитаем ещё раз через key в местах использования (или можно слушать event тут)
|
||||
[],
|
||||
);
|
||||
|
||||
const disabledBySettings = disableToolTip && !(essential && allowEssentialTooltips);
|
||||
|
||||
// Если отключено — просто возвращаем children без обёртки Tooltip
|
||||
if (disabledBySettings) return <>{children}</>;
|
||||
|
||||
return <StyledTooltip {...props}>{children}</StyledTooltip>;
|
||||
}
|
||||
@ -1,12 +1,77 @@
|
||||
import { Alert, Box, Snackbar, Button } from '@mui/material';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Snackbar,
|
||||
Button,
|
||||
Stack,
|
||||
Typography,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { styled, alpha, keyframes } from '@mui/material/styles';
|
||||
|
||||
export const GRADIENT =
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||
|
||||
const glowPulse = keyframes`
|
||||
0% { opacity: .6 }
|
||||
50% { opacity: 1 }
|
||||
100% { opacity: .6 }
|
||||
`;
|
||||
|
||||
export const GlassCard = styled(Paper)(() => ({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 18,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
boxShadow: '0 16px 40px rgba(0,0,0,0.45)',
|
||||
}));
|
||||
|
||||
export const Glow = styled('div')(() => ({
|
||||
position: 'absolute',
|
||||
inset: -2,
|
||||
background:
|
||||
'radial-gradient(400px 120px at 10% 0%, rgba(242,113,33,0.25), transparent 60%),' +
|
||||
'radial-gradient(400px 120px at 90% 0%, rgba(233,64,205,0.25), transparent 60%)',
|
||||
pointerEvents: 'none',
|
||||
animation: `${glowPulse} 5s ease-in-out infinite`,
|
||||
}));
|
||||
|
||||
export const GradientButton = styled(Button)(() => ({
|
||||
background: GRADIENT,
|
||||
borderRadius: 999,
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
'&:hover': {
|
||||
background: GRADIENT,
|
||||
filter: 'brightness(1.08)',
|
||||
},
|
||||
}));
|
||||
|
||||
export const SoftButton = styled(Button)(() => ({
|
||||
borderRadius: 999,
|
||||
textTransform: 'none',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.14)',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.1)' },
|
||||
}));
|
||||
|
||||
type Severity = 'error' | 'warning' | 'info' | 'success';
|
||||
|
||||
const severityColor: Record<Severity, string> = {
|
||||
info: '#4fc3f7',
|
||||
success: '#4caf50',
|
||||
warning: '#ff9800',
|
||||
error: '#f44336',
|
||||
};
|
||||
|
||||
export const Notifier = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [severity, setSeverity] = useState<
|
||||
'error' | 'warning' | 'info' | 'success'
|
||||
>('info');
|
||||
const [severity, setSeverity] = useState<Severity>('info');
|
||||
const [hasUpdateAvailable, setHasUpdateAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -24,6 +89,15 @@ export const Notifier = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
setMessage('Доступно новое обновление (dev preview)');
|
||||
setSeverity('info');
|
||||
setHasUpdateAvailable(true);
|
||||
setOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
@ -34,30 +108,48 @@ export const Notifier = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={hasUpdateAvailable ? null : 6000}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<Alert
|
||||
severity={severity}
|
||||
action={
|
||||
hasUpdateAvailable && (
|
||||
<>
|
||||
<Button color="primary" size="small" onClick={handleUpdate}>
|
||||
Обновить сейчас
|
||||
</Button>
|
||||
<Button color="secondary" size="small" onClick={handleClose}>
|
||||
Позже
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<Snackbar
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
autoHideDuration={hasUpdateAvailable ? null : 5000}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<GlassCard sx={{ minWidth: 340 }}>
|
||||
<Glow />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
position: 'relative',
|
||||
gap: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.82)',
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.35,
|
||||
pr: hasUpdateAvailable ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Typography>
|
||||
|
||||
{hasUpdateAvailable && (
|
||||
<Stack direction="row" spacing={1} justifyContent="center" gap={1}>
|
||||
<SoftButton size="small" onClick={handleUpdate}>
|
||||
Обновить
|
||||
</SoftButton>
|
||||
|
||||
<SoftButton size="small" onClick={handleClose}>
|
||||
Позже
|
||||
</SoftButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</GlassCard>
|
||||
</Snackbar>
|
||||
);
|
||||
};
|
||||
|
||||
490
src/renderer/components/OnlinePlayersPanel.tsx
Normal file
490
src/renderer/components/OnlinePlayersPanel.tsx
Normal file
@ -0,0 +1,490 @@
|
||||
// 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.length ? (
|
||||
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)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
py: '1.4vw',
|
||||
px: '1.1vw',
|
||||
borderRadius: '1.1vw',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
border: '1px dashed rgba(255,255,255,0.14)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '0.95vw',
|
||||
color: 'rgba(255,255,255,0.78)',
|
||||
}}
|
||||
>
|
||||
Сейчас на сервере никого нет!
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
145
src/renderer/components/PageHeader.tsx
Normal file
145
src/renderer/components/PageHeader.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
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 === '/inventory' ||
|
||||
path === '/fakepaymentpage' ||
|
||||
path === '/promocode' ||
|
||||
path === '/voice' ||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
// src/renderer/components/PlayerInventory.tsx
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useImperativeHandle, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@ -8,7 +8,6 @@ import {
|
||||
CardMedia,
|
||||
CardContent,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
@ -22,6 +21,119 @@ import {
|
||||
sellItem,
|
||||
PlayerInventoryItem,
|
||||
} from '../api';
|
||||
import { FullScreenLoader } from './FullScreenLoader';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
|
||||
const GRADIENT =
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||
|
||||
const GLASS_PAPER_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',
|
||||
backdropFilter: 'blur(16px)',
|
||||
} as const;
|
||||
|
||||
const DIALOG_TITLE_SX = {
|
||||
fontFamily: 'Benzin-Bold',
|
||||
pr: 6,
|
||||
position: 'relative',
|
||||
} as const;
|
||||
|
||||
const CLOSE_BTN_SX = {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
'&:hover': { transform: 'scale(1.05)', background: 'rgba(255,255,255,0.10)' },
|
||||
transition: 'all 0.2s ease',
|
||||
} as const;
|
||||
|
||||
const DIVIDERS_SX = {
|
||||
borderColor: 'rgba(255,255,255,0.10)',
|
||||
} as const;
|
||||
|
||||
const INPUT_SX = {
|
||||
mt: 1.2,
|
||||
|
||||
'& .MuiInputLabel-root': {
|
||||
color: 'rgba(255,255,255,0.72)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
letterSpacing: 0.3,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
'& .MuiInputLabel-root.Mui-focused': {
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
},
|
||||
|
||||
'& .MuiOutlinedInput-root': {
|
||||
position: 'relative',
|
||||
borderRadius: '1.1vw',
|
||||
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.10)',
|
||||
boxShadow: '0 1.2vw 3.0vw rgba(0,0,0,0.55)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
|
||||
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
|
||||
'& input': {
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1.0rem',
|
||||
padding: '1.0vw 1.0vw',
|
||||
color: 'rgba(255,255,255,0.95)',
|
||||
},
|
||||
|
||||
transition: 'transform 0.18s ease, filter 0.18s ease, border-color 0.18s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.01)',
|
||||
borderColor: 'rgba(255,255,255,0.14)',
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
borderColor: 'rgba(255,255,255,0.18)',
|
||||
filter: 'brightness(1.03)',
|
||||
},
|
||||
|
||||
'&:after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: '0.18vw',
|
||||
borderRadius: '999px',
|
||||
background: GRADIENT,
|
||||
opacity: 0.92,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PRIMARY_BTN_SX = {
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: '#fff',
|
||||
background: GRADIENT,
|
||||
borderRadius: '999px',
|
||||
px: '1.6vw',
|
||||
py: '0.65vw',
|
||||
boxShadow: '0 1.0vw 2.6vw rgba(0,0,0,0.45)',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
} as const;
|
||||
|
||||
const SECONDARY_BTN_SX = {
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
} as const;
|
||||
|
||||
interface PlayerInventoryProps {
|
||||
username: string;
|
||||
@ -29,11 +141,12 @@ interface PlayerInventoryProps {
|
||||
onSellSuccess?: () => void; // Callback для обновления маркетплейса после продажи
|
||||
}
|
||||
|
||||
export default function PlayerInventory({
|
||||
username,
|
||||
serverIp,
|
||||
onSellSuccess,
|
||||
}: PlayerInventoryProps) {
|
||||
export type PlayerInventoryHandle = {
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
const PlayerInventory = React.forwardRef<PlayerInventoryHandle, PlayerInventoryProps>(
|
||||
({ username, serverIp, onSellSuccess }, ref) => {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [inventoryItems, setInventoryItems] = useState<PlayerInventoryItem[]>(
|
||||
[],
|
||||
@ -48,6 +161,8 @@ export default function PlayerInventory({
|
||||
const [sellLoading, setSellLoading] = useState<boolean>(false);
|
||||
const [sellError, setSellError] = useState<string | null>(null);
|
||||
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// Функция для запроса инвентаря игрока
|
||||
const fetchPlayerInventory = async () => {
|
||||
try {
|
||||
@ -96,6 +211,12 @@ export default function PlayerInventory({
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh: async () => {
|
||||
await fetchPlayerInventory();
|
||||
},
|
||||
}));
|
||||
|
||||
// Открываем диалог для продажи предмета
|
||||
const handleOpenSellDialog = (item: PlayerInventoryItem) => {
|
||||
setSelectedItem(item);
|
||||
@ -137,6 +258,7 @@ export default function PlayerInventory({
|
||||
amount,
|
||||
price,
|
||||
serverIp,
|
||||
description,
|
||||
);
|
||||
|
||||
// Проверяем статус операции
|
||||
@ -183,7 +305,7 @@ export default function PlayerInventory({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: '1vw' }}>
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@ -195,25 +317,6 @@ export default function PlayerInventory({
|
||||
<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 && (
|
||||
@ -223,9 +326,7 @@ export default function PlayerInventory({
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
<FullScreenLoader fullScreen={false} message="Загрузка инвентаря..." />
|
||||
) : (
|
||||
<>
|
||||
{inventoryItems.length === 0 ? (
|
||||
@ -237,10 +338,15 @@ export default function PlayerInventory({
|
||||
Ваш инвентарь пуст или не удалось загрузить предметы.
|
||||
</Typography>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
columns={10}
|
||||
sx={{ justifyContent: 'center' }}
|
||||
>
|
||||
{inventoryItems.map((item) =>
|
||||
item.material !== 'AIR' && item.amount > 0 ? (
|
||||
<Grid item xs={6} sm={4} md={3} lg={2} key={item.slot}>
|
||||
<Grid item xs={1} key={item.slot}>
|
||||
<Card
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
@ -258,19 +364,33 @@ export default function PlayerInventory({
|
||||
minHeight: '10vw',
|
||||
maxHeight: '10vw',
|
||||
objectFit: 'contain',
|
||||
bgcolor: 'white',
|
||||
p: '1vw',
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
image={`/minecraft/${item.material.toLowerCase()}.png`}
|
||||
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>
|
||||
<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">
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="white"
|
||||
sx={{ fontSize: '0.8vw' }}
|
||||
>
|
||||
{item.amount > 1 ? `x${item.amount}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
@ -278,7 +398,7 @@ export default function PlayerInventory({
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="secondary"
|
||||
sx={{ display: 'block' }}
|
||||
sx={{ display: 'block', fontSize: '0.8vw' }}
|
||||
>
|
||||
Зачарования: {Object.keys(item.enchants).length}
|
||||
</Typography>
|
||||
@ -294,79 +414,157 @@ export default function PlayerInventory({
|
||||
)}
|
||||
|
||||
{/* Диалог для продажи предмета */}
|
||||
<Dialog open={sellDialogOpen} onClose={handleCloseSellDialog}>
|
||||
<DialogTitle>Продать предмет</DialogTitle>
|
||||
<DialogContent>
|
||||
<Dialog
|
||||
open={sellDialogOpen}
|
||||
onClose={handleCloseSellDialog}
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
PaperProps={{ sx: GLASS_PAPER_SX }}
|
||||
>
|
||||
<DialogTitle sx={DIALOG_TITLE_SX}>
|
||||
Продать предмет
|
||||
<IconButton onClick={handleCloseSellDialog} sx={CLOSE_BTN_SX}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers sx={DIVIDERS_SX}>
|
||||
{selectedItem && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<CardMedia
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
{/* Верхняя карточка предмета */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1vw',
|
||||
p: '0.9vw',
|
||||
borderRadius: '1.1vw',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
mb: 1.1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
sx={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
objectFit: 'contain',
|
||||
mr: 2,
|
||||
}}
|
||||
image={`/items/${selectedItem.material.toLowerCase()}.png`}
|
||||
src={`https://cdn.minecraft.popa-popa.ru/textures/${selectedItem.material.toLowerCase()}.png`}
|
||||
alt={selectedItem.material}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: 54,
|
||||
height: 54,
|
||||
objectFit: 'contain',
|
||||
imageRendering: 'pixelated',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6">
|
||||
{getItemDisplayName(selectedItem.material)}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1.05rem',
|
||||
lineHeight: 1.1,
|
||||
color: 'rgba(255,255,255,0.95)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
title={getItemDisplayName(selectedItem.material)}
|
||||
>
|
||||
{getItemDisplayName(selectedItem.material)}
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.70)', fontWeight: 800, mt: 0.4 }}>
|
||||
Доступно: <span style={{ color: 'rgba(255,255,255,0.92)' }}>{selectedItem.amount}</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
</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,
|
||||
),
|
||||
)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
const safe = Number.isFinite(v) ? v : 0;
|
||||
setAmount(Math.min(Math.max(1, safe), selectedItem.amount));
|
||||
}}
|
||||
inputProps={{ min: 1, max: selectedItem.amount }}
|
||||
sx={INPUT_SX}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Цена (за всё)"
|
||||
type="number"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(parseInt(e.target.value) || 0)}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setPrice(Number.isFinite(v) ? v : 0);
|
||||
}}
|
||||
inputProps={{ min: 1 }}
|
||||
sx={INPUT_SX}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Описание (необязательно)"
|
||||
fullWidth
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
inputProps={{ min: 1 }}
|
||||
sx={INPUT_SX}
|
||||
/>
|
||||
|
||||
{sellError && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1.2,
|
||||
p: '0.9vw',
|
||||
borderRadius: '1.0vw',
|
||||
border: '1px solid rgba(255,70,70,0.22)',
|
||||
background: 'rgba(255,70,70,0.12)',
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
{sellError}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Подсказка */}
|
||||
<Typography sx={{ mt: 1.1, color: 'rgba(255,255,255,0.60)', fontWeight: 700 }}>
|
||||
Цена указывается за весь лот!
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseSellDialog}>Отмена</Button>
|
||||
|
||||
<DialogActions sx={{ p: '1.2vw' }}>
|
||||
<Button onClick={handleCloseSellDialog} sx={SECONDARY_BTN_SX}>
|
||||
Отмена
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleSellItem}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disableRipple
|
||||
disabled={sellLoading}
|
||||
sx={{
|
||||
...PRIMARY_BTN_SX,
|
||||
...(sellLoading
|
||||
? {
|
||||
background: 'rgba(255,255,255,0.10)',
|
||||
boxShadow: 'none',
|
||||
color: 'rgba(255,255,255,0.55)',
|
||||
}
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
{sellLoading ? <CircularProgress size={24} /> : 'Продать'}
|
||||
{sellLoading ? 'Выставляем…' : 'Продать'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
})
|
||||
|
||||
export default PlayerInventory;
|
||||
|
||||
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';
|
||||
|
||||
interface ServerStatusProps {
|
||||
|
||||
@ -10,7 +10,7 @@ interface SettingsModalProps {
|
||||
memory: number;
|
||||
preserveFiles: string[];
|
||||
};
|
||||
onConfigChange: (newConfig: {
|
||||
onConfigChange: (updater: (prev: { memory: number; preserveFiles: string[] }) => {
|
||||
memory: number;
|
||||
preserveFiles: string[];
|
||||
}) => void;
|
||||
@ -58,7 +58,7 @@ const SettingsModal = ({
|
||||
packName={packName}
|
||||
initialSelected={config.preserveFiles}
|
||||
onSelectionChange={(selected) => {
|
||||
onConfigChange({ ...config, preserveFiles: selected });
|
||||
onConfigChange((prev) => ({ ...prev, preserveFiles: selected }));
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body1" sx={{ color: 'white' }}>
|
||||
@ -66,8 +66,9 @@ const SettingsModal = ({
|
||||
</Typography>
|
||||
<MemorySlider
|
||||
memory={config.memory}
|
||||
onChange={(e, value) => {
|
||||
onConfigChange({ ...config, memory: value as number });
|
||||
onChange={(_, value) => {
|
||||
const next = Array.isArray(value) ? value[0] : value;
|
||||
onConfigChange((prev) => ({ ...prev, memory: next }));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
|
||||
274
src/renderer/components/ShopItem.tsx
Normal file
274
src/renderer/components/ShopItem.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
// 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';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import CaseItemsDialog from './CaseItemsDialog';
|
||||
|
||||
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,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
imageUrl,
|
||||
price,
|
||||
itemsCount,
|
||||
isOpening,
|
||||
disabled,
|
||||
playerSkinUrl,
|
||||
onClick,
|
||||
}: ShopItemProps) {
|
||||
const buttonText =
|
||||
type === 'case' ? (isOpening ? 'Открываем...' : 'Открыть кейс') : 'Купить';
|
||||
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [caseInfoOpen, setCaseInfoOpen] = 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': {
|
||||
borderColor: 'rgba(200, 33, 242, 0.35)',
|
||||
boxShadow: '0 1.2vw 3.2vw rgba(53, 3, 66, 0.75)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Градиентный свет сверху */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
background:
|
||||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.10), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.16), transparent 55%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{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' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
Предметов в кейсе: {itemsCount}
|
||||
</Typography>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // важно: чтобы не сработало onClick карточки/кнопки открытия
|
||||
setCaseInfoOpen(true);
|
||||
}}
|
||||
sx={{
|
||||
ml: 1,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
'&:hover': { transform: 'scale(1.05)' },
|
||||
transition: 'all 0.25s ease',
|
||||
}}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</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',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
{type === 'cape' && imageUrl && (
|
||||
<CapePreviewModal
|
||||
open={previewOpen}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
capeUrl={imageUrl}
|
||||
skinUrl={playerSkinUrl}
|
||||
/>
|
||||
)}
|
||||
{type === 'case' && (
|
||||
<CaseItemsDialog
|
||||
open={caseInfoOpen}
|
||||
onClose={() => setCaseInfoOpen(false)}
|
||||
caseId={id}
|
||||
caseName={name}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,9 @@ interface SkinViewerProps {
|
||||
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,
|
||||
@ -19,55 +22,111 @@ export default function SkinViewer({
|
||||
}: SkinViewerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const viewerRef = useRef<any>(null);
|
||||
const animRef = useRef<any>(null);
|
||||
|
||||
// 1) Инициализируем viewer ОДИН РАЗ
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
let disposed = false;
|
||||
|
||||
const init = async () => {
|
||||
if (!canvasRef.current || viewerRef.current) return;
|
||||
|
||||
// Используем динамический импорт для обхода проблемы ESM/CommonJS
|
||||
const initSkinViewer = async () => {
|
||||
try {
|
||||
const skinview3d = await import('skinview3d');
|
||||
if (disposed) return;
|
||||
|
||||
// Создаем просмотрщик скина по документации
|
||||
const viewer = new skinview3d.SkinViewer({
|
||||
canvas: canvasRef.current,
|
||||
width,
|
||||
height,
|
||||
skin: skinUrl || undefined,
|
||||
model: 'auto-detect',
|
||||
cape: capeUrl || undefined,
|
||||
});
|
||||
|
||||
// Настраиваем вращение
|
||||
// базовая настройка
|
||||
viewer.autoRotate = autoRotate;
|
||||
|
||||
// Настраиваем анимацию ходьбы
|
||||
viewer.animation = new skinview3d.WalkingAnimation();
|
||||
viewer.animation.speed = walkingSpeed;
|
||||
// анимация ходьбы
|
||||
const walking = new skinview3d.WalkingAnimation();
|
||||
walking.speed = walkingSpeed;
|
||||
viewer.animation = walking;
|
||||
|
||||
// Сохраняем экземпляр для очистки
|
||||
viewerRef.current = viewer;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при инициализации skinview3d:', error);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
initSkinViewer();
|
||||
init();
|
||||
|
||||
// Очистка при размонтировании
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (viewerRef.current) {
|
||||
viewerRef.current.dispose();
|
||||
viewerRef.current = null;
|
||||
animRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [width, height, skinUrl, capeUrl, walkingSpeed, autoRotate]);
|
||||
// ⚠️ пустой deps — создаём один раз
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
);
|
||||
// 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' }} />;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
112
src/renderer/components/Voice/CreateRoomDialog.tsx
Normal file
112
src/renderer/components/Voice/CreateRoomDialog.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function CreateRoomDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (data: { name: string; isPublic: boolean }) => Promise<{
|
||||
invite_code?: string | null;
|
||||
}>;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
const [inviteCode, setInviteCode] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const res = await onCreate({
|
||||
name,
|
||||
isPublic,
|
||||
});
|
||||
|
||||
if (!isPublic && res?.invite_code) {
|
||||
setInviteCode(res.invite_code);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Создать комнату</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{!inviteCode ? (
|
||||
<>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="Название комнаты"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
margin="dense"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={isPublic ? 'Публичная' : 'Приватная'}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography sx={{ mb: 1 }}>Код для входа в комнату:</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
value={inviteCode}
|
||||
InputProps={{ readOnly: true }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
sx={{ mt: 2 }}
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(inviteCode);
|
||||
}}
|
||||
>
|
||||
Скопировать код
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Закрыть</Button>
|
||||
|
||||
{!inviteCode && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleCreate}
|
||||
disabled={!name || loading}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
58
src/renderer/components/Voice/JoinByCodeDialog.tsx
Normal file
58
src/renderer/components/Voice/JoinByCodeDialog.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function JoinByCodeDialog({
|
||||
open,
|
||||
onClose,
|
||||
onJoin,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onJoin: (code: string) => Promise<void>;
|
||||
}) {
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleJoin = async () => {
|
||||
setLoading(true);
|
||||
await onJoin(code);
|
||||
setLoading(false);
|
||||
setCode('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Войти по коду</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="Invite-код"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
margin="dense"
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleJoin}
|
||||
disabled={!code || loading}
|
||||
>
|
||||
Войти
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
115
src/renderer/components/Voice/RoomsPanel.tsx
Normal file
115
src/renderer/components/Voice/RoomsPanel.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import { Box, Typography, Button } from '@mui/material';
|
||||
import { glassBox, GRADIENT } from '../../theme/voiceStyles';
|
||||
import type { RoomInfo } from '../../types/rooms';
|
||||
|
||||
type Props = {
|
||||
rooms: RoomInfo[];
|
||||
currentRoomId: string | null;
|
||||
onJoin: (roomId: string) => void;
|
||||
onCreate: () => void;
|
||||
onJoinByCode: () => void;
|
||||
};
|
||||
|
||||
export function RoomsPanel({
|
||||
rooms,
|
||||
currentRoomId,
|
||||
onJoin,
|
||||
onCreate,
|
||||
onJoinByCode,
|
||||
}: Props) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 300,
|
||||
p: '1.2vw',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1vw',
|
||||
borderRadius: '1.5vw',
|
||||
...glassBox,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1.4vw',
|
||||
backgroundImage: GRADIENT,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
Голосовые комнаты
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', pr: '0.3vw' }}>
|
||||
{rooms.map((room) => {
|
||||
const active = room.id === currentRoomId;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={room.id}
|
||||
onClick={() => onJoin(room.id)}
|
||||
sx={{
|
||||
p: '0.9vw',
|
||||
mb: '0.6vw',
|
||||
borderRadius: '1vw',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.18s ease',
|
||||
background: active ? GRADIENT : 'rgba(255,255,255,0.04)',
|
||||
color: active ? '#fff' : 'rgba(255,255,255,0.9)',
|
||||
'&:hover': {
|
||||
//transform: 'scale(1.01)',
|
||||
background: active ? GRADIENT : 'rgba(255,255,255,0.07)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 800 }}>{room.name}</Typography>
|
||||
|
||||
{room.users.length > 0 && (
|
||||
<Box sx={{ mt: '0.4vw', pl: '0.8vw' }}>
|
||||
{room.users.map((u) => (
|
||||
<Typography
|
||||
key={u}
|
||||
sx={{
|
||||
fontSize: '0.85vw',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
• {u}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
onClick={onCreate}
|
||||
sx={{
|
||||
borderRadius: '999px',
|
||||
py: '0.8vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
background: GRADIENT,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
+ Создать комнату
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onJoinByCode}
|
||||
sx={{
|
||||
borderRadius: '999px',
|
||||
py: '0.8vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
Войти по коду
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
131
src/renderer/components/Voice/VoicePanel.tsx
Normal file
131
src/renderer/components/Voice/VoicePanel.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { Box, Button, Typography } from '@mui/material';
|
||||
import { useVoiceRoom } from '../../realtime/voice/useVoiceRoom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getVoiceState, subscribeVoice } from '../../realtime/voice/voiceStore';
|
||||
import { glassBox, GRADIENT } from '../../theme/voiceStyles';
|
||||
import { HeadAvatar } from '../HeadAvatar';
|
||||
import { API_BASE_URL } from '../../api';
|
||||
|
||||
type Props = {
|
||||
roomId: string;
|
||||
voice: {
|
||||
disconnect: () => void;
|
||||
toggleMute: () => void;
|
||||
};
|
||||
roomName: string;
|
||||
};
|
||||
|
||||
export function VoicePanel({ roomId, voice, roomName }: Props) {
|
||||
const [state, setState] = useState(getVoiceState());
|
||||
const [skinMap, setSkinMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const loadSkins = async () => {
|
||||
const missing = state.participants.filter((u) => !skinMap[u]);
|
||||
if (!missing.length) return;
|
||||
|
||||
try {
|
||||
// ⚠️ ВАЖНО: полный URL до API
|
||||
const res = await fetch(API_BASE_URL + '/users');
|
||||
const data = await res.json();
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
for (const user of data.users) {
|
||||
if (missing.includes(user.username) && user.skin_url) {
|
||||
map[user.username] = user.skin_url;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(map).length) {
|
||||
setSkinMap((prev) => ({ ...prev, ...map }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Не удалось загрузить скины для голосового чата', e);
|
||||
}
|
||||
};
|
||||
|
||||
loadSkins();
|
||||
}, [state.participants]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeVoice(() => {
|
||||
setState({ ...getVoiceState() });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
console.log('participants:', state.participants);
|
||||
console.log('skinMap:', skinMap);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
ml: '1.5vw',
|
||||
p: '2vw',
|
||||
borderRadius: '1.5vw',
|
||||
...glassBox,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '2vw',
|
||||
mb: '1.2vw',
|
||||
backgroundImage: GRADIENT,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{roomName}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: '1vw', mb: '2vw' }}>
|
||||
<Button
|
||||
onClick={voice.toggleMute}
|
||||
sx={{
|
||||
borderRadius: '999px',
|
||||
px: '2vw',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
color: '#fff',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
}}
|
||||
>
|
||||
{state.muted ? 'Вкл. микрофон' : 'Выкл. микрофон'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={voice.disconnect}
|
||||
sx={{
|
||||
borderRadius: '999px',
|
||||
px: '2vw',
|
||||
background: 'rgba(255,60,60,0.25)',
|
||||
color: '#fff',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography sx={{ mb: '0.6vw', opacity: 0.7 }}>Участники:</Typography>
|
||||
|
||||
{state.participants.map((u) => (
|
||||
<Box
|
||||
key={u}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.6vw',
|
||||
mb: '0.3vw',
|
||||
}}
|
||||
>
|
||||
<HeadAvatar skinUrl={skinMap[u]} size={28} />
|
||||
|
||||
<Typography>{u}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -10,7 +10,8 @@ export default function PopaPopa() {
|
||||
<Typography
|
||||
variant="h3"
|
||||
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',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
|
||||
@ -1,27 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface AuthSession {
|
||||
accessToken: string;
|
||||
clientToken: string;
|
||||
selectedProfile: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
import {
|
||||
authenticate,
|
||||
validateToken,
|
||||
refreshToken,
|
||||
type AuthSession,
|
||||
} from '../api';
|
||||
|
||||
export default function useAuth() {
|
||||
const [status, setStatus] = useState('idle');
|
||||
const [status, setStatus] = useState<
|
||||
'idle' | 'validating' | 'refreshing' | 'authenticating' | 'error'
|
||||
>('idle');
|
||||
|
||||
// Проверка валидности токена
|
||||
// Аутентификация (HTTP напрямую, без IPC!)
|
||||
const authenticateUser = async (
|
||||
username: string,
|
||||
password: string,
|
||||
saveConfigFunc: (config: any) => void,
|
||||
): Promise<AuthSession | null> => {
|
||||
try {
|
||||
setStatus('authenticating');
|
||||
|
||||
// Прямой HTTP-запрос к вашему серверу
|
||||
const session = await authenticate(username, password);
|
||||
await applySession(session, saveConfigFunc);
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при аутентификации:', error);
|
||||
setStatus('error');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const applySession = async (
|
||||
session: AuthSession,
|
||||
saveConfigFunc: (config: any) => void,
|
||||
) => {
|
||||
saveConfigFunc({
|
||||
username: session.selectedProfile.name,
|
||||
uuid: session.selectedProfile.id,
|
||||
accessToken: session.accessToken,
|
||||
clientToken: session.clientToken,
|
||||
memory: 4096,
|
||||
});
|
||||
|
||||
await window.electron.ipcRenderer.invoke('auth-changed', {
|
||||
isAuthed: true,
|
||||
minecraftSession: session,
|
||||
});
|
||||
};
|
||||
|
||||
// Валидация токена (HTTP напрямую)
|
||||
const validateSession = async (accessToken: string): Promise<boolean> => {
|
||||
try {
|
||||
setStatus('validating');
|
||||
const response = await window.electron.ipcRenderer.invoke(
|
||||
'validate-token',
|
||||
accessToken,
|
||||
);
|
||||
|
||||
// Получаем clientToken из localStorage
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
if (!savedConfig) return false;
|
||||
|
||||
const config = JSON.parse(savedConfig);
|
||||
|
||||
// Прямой HTTP-запрос на валидацию
|
||||
const isValid = await validateToken(accessToken, config.clientToken);
|
||||
|
||||
setStatus('idle');
|
||||
return response.valid;
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при валидации токена:', error);
|
||||
setStatus('error');
|
||||
@ -29,66 +72,31 @@ export default function useAuth() {
|
||||
}
|
||||
};
|
||||
|
||||
// Обновление токена
|
||||
// Обновление токена (HTTP напрямую)
|
||||
const refreshSession = async (
|
||||
accessToken: string,
|
||||
clientToken: string,
|
||||
): Promise<AuthSession | null> => {
|
||||
try {
|
||||
setStatus('refreshing');
|
||||
const response = await window.electron.ipcRenderer.invoke(
|
||||
'refresh-token',
|
||||
{ accessToken, clientToken },
|
||||
);
|
||||
|
||||
// Прямой HTTP-запрос на обновление
|
||||
const session = await refreshToken(accessToken, clientToken);
|
||||
|
||||
setStatus('idle');
|
||||
return response;
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении токена:', error);
|
||||
setStatus('error');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Аутентификация в Ely.by
|
||||
const authenticateWithElyBy = async (
|
||||
username: string,
|
||||
password: string,
|
||||
saveConfigFunc: Function,
|
||||
): Promise<AuthSession | null> => {
|
||||
try {
|
||||
setStatus('authenticating');
|
||||
const response = await window.electron.ipcRenderer.invoke(
|
||||
'authenticate',
|
||||
{ username, password },
|
||||
);
|
||||
|
||||
if (response && response.accessToken) {
|
||||
// Правильно сохраняем данные в конфигурации
|
||||
saveConfigFunc({
|
||||
username: response.selectedProfile.name, // Имя игрока как строка
|
||||
uuid: response.selectedProfile.id,
|
||||
accessToken: response.accessToken,
|
||||
clientToken: response.clientToken,
|
||||
memory: 4096, // Сохраняем значение по умолчанию или из предыдущей конфигурации
|
||||
});
|
||||
|
||||
setStatus('authenticated');
|
||||
return response;
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при аутентификации:', error);
|
||||
setStatus('error');
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
status,
|
||||
authenticateUser,
|
||||
validateSession,
|
||||
refreshSession,
|
||||
authenticateWithElyBy,
|
||||
applySession,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
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 root = createRoot(container);
|
||||
root.render(<App />);
|
||||
|
||||
root.render(
|
||||
<ThemeProvider theme={defaultTheme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
// calling IPC exposed from preload script
|
||||
window.electron?.ipcRenderer.once('ipc-example', (arg) => {
|
||||
|
||||
11
src/renderer/mappers/roomMapper.ts
Normal file
11
src/renderer/mappers/roomMapper.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { ApiRoom } from '../api/voiceRooms';
|
||||
import type { RoomInfo } from '../types/rooms';
|
||||
|
||||
export function mapApiRoomToUI(room: ApiRoom): RoomInfo {
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
public: room.public,
|
||||
users: room.usernames ?? [], // ⬅️ ВАЖНО: список пользователей приходит ТОЛЬКО по WS
|
||||
};
|
||||
}
|
||||
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 essential 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 essential 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 essential 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>
|
||||
);
|
||||
}
|
||||
519
src/renderer/pages/FakePaymentPage.tsx
Normal file
519
src/renderer/pages/FakePaymentPage.tsx
Normal file
@ -0,0 +1,519 @@
|
||||
// pages/TopUpPage.tsx
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import fakePaymentImg from '../../../assets/images/fake-payment.png';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
type PayMethod = 'sbp' | 'card' | 'crypto' | 'other';
|
||||
type Stage = 'form' | 'processing' | 'done';
|
||||
|
||||
const STEPS: string[] = [
|
||||
'Создаём счёт…',
|
||||
'Проверяем данные…',
|
||||
'Подключаем платёжный шлюз…',
|
||||
'Ожидаем подтверждение…',
|
||||
'Подписываем запрос…',
|
||||
'Проверяем лимиты…',
|
||||
'Синхронизируем баланс…',
|
||||
'Завершаем операцию…',
|
||||
'Почти готово…',
|
||||
];
|
||||
|
||||
// ===== Styles “как Registration” =====
|
||||
const GRADIENT =
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||
|
||||
const GlassPaper = styled(Paper)(() => ({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 28,
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
|
||||
}));
|
||||
|
||||
const Glow = styled('div')(() => ({
|
||||
position: 'absolute',
|
||||
inset: -2,
|
||||
background:
|
||||
'radial-gradient(800px 300px at 20% 10%, rgba(242,113,33,0.22), transparent 60%),' +
|
||||
'radial-gradient(800px 300px at 80% 0%, rgba(233,64,205,0.18), transparent 55%),' +
|
||||
'radial-gradient(900px 420px at 50% 110%, rgba(138,35,135,0.20), transparent 60%)',
|
||||
pointerEvents: 'none',
|
||||
}));
|
||||
|
||||
const GradientTitle = styled(Typography)(() => ({
|
||||
fontWeight: 900,
|
||||
backgroundImage:
|
||||
'linear-gradient(136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
fontFamily: 'Benzin-Bold, sans-serif',
|
||||
}));
|
||||
|
||||
const GradientButton = styled(Button)(() => ({
|
||||
background: GRADIENT,
|
||||
fontFamily: 'Benzin-Bold, sans-serif',
|
||||
borderRadius: 999,
|
||||
textTransform: 'none',
|
||||
transition: 'transform 0.25s ease, filter 0.25s ease, box-shadow 0.25s ease',
|
||||
boxShadow: '0 12px 30px rgba(0,0,0,0.35)',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.04)',
|
||||
filter: 'brightness(1.06)',
|
||||
boxShadow: '0 16px 42px rgba(0,0,0,0.48)',
|
||||
background: GRADIENT,
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledToggleButtonGroup = styled(ToggleButtonGroup)(() => ({
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
'& .MuiToggleButton-root': {
|
||||
border: 'none',
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
fontFamily: 'Benzin-Bold, sans-serif',
|
||||
letterSpacing: '0.02em',
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
transition: 'transform 0.2s ease, background 0.2s ease, color 0.2s ease',
|
||||
},
|
||||
'& .MuiToggleButton-root:hover': {
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
transform: 'scale(1.02)',
|
||||
},
|
||||
'& .MuiToggleButton-root.Mui-selected': {
|
||||
color: '#fff',
|
||||
background: GRADIENT,
|
||||
},
|
||||
'& .MuiToggleButton-root.Mui-selected:hover': {
|
||||
background: GRADIENT,
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledTextField = styled(TextField)(() => ({
|
||||
'& .MuiInputLabel-root': {
|
||||
color: 'rgba(255,255,255,0.65)',
|
||||
},
|
||||
'& .MuiInputLabel-root.Mui-focused': {
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 20,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
color: '#fff',
|
||||
fontFamily: 'Benzin-Bold, sans-serif',
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'rgba(255,255,255,0.10)',
|
||||
},
|
||||
'& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'rgba(255,255,255,0.18)',
|
||||
},
|
||||
'& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'rgba(233,64,205,0.55)',
|
||||
boxShadow: '0 0 0 6px rgba(233,64,205,0.12)',
|
||||
},
|
||||
'& input': {
|
||||
color: '#fff',
|
||||
},
|
||||
}));
|
||||
|
||||
export default function TopUpPage() {
|
||||
const [coins, setCoins] = useState<number>(100);
|
||||
const [method, setMethod] = useState<PayMethod>('sbp');
|
||||
const [stage, setStage] = useState<Stage>('form');
|
||||
|
||||
const [stepText, setStepText] = useState<string>('Обработка платежа…');
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
|
||||
const doneTimerRef = useRef<number | null>(null);
|
||||
const stepIntervalRef = useRef<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const rubles = useMemo(() => {
|
||||
const safe = Number.isFinite(coins) ? coins : 0;
|
||||
return Math.max(0, Math.floor(safe));
|
||||
}, [coins]);
|
||||
|
||||
const methodLabel = useMemo(() => {
|
||||
switch (method) {
|
||||
case 'sbp':
|
||||
return 'СБП';
|
||||
case 'card':
|
||||
return 'Карта';
|
||||
case 'crypto':
|
||||
return 'Crypto';
|
||||
default:
|
||||
return 'Другое';
|
||||
}
|
||||
}, [method]);
|
||||
|
||||
const clearTimers = () => {
|
||||
if (doneTimerRef.current !== null) {
|
||||
window.clearTimeout(doneTimerRef.current);
|
||||
doneTimerRef.current = null;
|
||||
}
|
||||
if (stepIntervalRef.current !== null) {
|
||||
window.clearInterval(stepIntervalRef.current);
|
||||
stepIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startProcessing = () => {
|
||||
clearTimers();
|
||||
setStage('processing');
|
||||
setProgress(0);
|
||||
|
||||
const used = new Set<number>();
|
||||
const pickStep = () => {
|
||||
if (used.size >= STEPS.length) used.clear();
|
||||
let idx = Math.floor(Math.random() * STEPS.length);
|
||||
while (used.has(idx)) idx = Math.floor(Math.random() * STEPS.length);
|
||||
used.add(idx);
|
||||
return STEPS[idx];
|
||||
};
|
||||
|
||||
setStepText(pickStep());
|
||||
|
||||
const totalMs = 1600 + Math.floor(Math.random() * 1600); // 1.6–3.2
|
||||
const stepsCount = 3 + Math.floor(Math.random() * 4); // 3–6
|
||||
|
||||
let ticks = 0;
|
||||
stepIntervalRef.current = window.setInterval(
|
||||
() => {
|
||||
ticks += 1;
|
||||
|
||||
setStepText(pickStep());
|
||||
setProgress((p) => {
|
||||
const bump = 8 + Math.floor(Math.random() * 18); // 8..25
|
||||
return Math.min(95, p + bump);
|
||||
});
|
||||
|
||||
if (ticks >= stepsCount && stepIntervalRef.current !== null) {
|
||||
window.clearInterval(stepIntervalRef.current);
|
||||
stepIntervalRef.current = null;
|
||||
}
|
||||
},
|
||||
400 + Math.floor(Math.random() * 500),
|
||||
); // 400..900
|
||||
|
||||
doneTimerRef.current = window.setTimeout(() => {
|
||||
setProgress(100);
|
||||
setStepText('Готово!');
|
||||
window.setTimeout(() => setStage('done'), 250);
|
||||
doneTimerRef.current = null;
|
||||
}, totalMs);
|
||||
};
|
||||
|
||||
const handlePay = () => {
|
||||
if (rubles <= 0) return;
|
||||
startProcessing();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ===== Layout wrapper =====
|
||||
const PageCenter = ({ children }: { children: React.ReactNode }) => (
|
||||
<Box
|
||||
sx={{
|
||||
height: 'calc(100vh - 8vh)',
|
||||
pt: '8vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ===== DONE =====
|
||||
if (stage === 'done') {
|
||||
return (
|
||||
<PageCenter>
|
||||
<GlassPaper sx={{ width: 'min(680px, 92vw)', p: 3 }}>
|
||||
<Glow />
|
||||
<Stack
|
||||
spacing={2.2}
|
||||
alignItems="center"
|
||||
sx={{ position: 'relative' }}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={fakePaymentImg}
|
||||
alt="payment"
|
||||
sx={{
|
||||
width: 'min(440px, 82vw)',
|
||||
height: 'auto',
|
||||
borderRadius: '24px',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.55)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<GradientTitle variant="h5" sx={{ textAlign: 'center' }}>
|
||||
Че реально думал донат добавили?
|
||||
</GradientTitle>
|
||||
|
||||
<Typography sx={{ opacity: 0.8, textAlign: 'center' }}>
|
||||
Хуй тебе а не донат
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
navigate('/');
|
||||
}}
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderRadius: 999,
|
||||
px: 3,
|
||||
borderColor: 'rgba(255,255,255,0.18)',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
textTransform: 'none',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255,255,255,0.30)',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Вернуться назад
|
||||
</Button>
|
||||
</Stack>
|
||||
</GlassPaper>
|
||||
</PageCenter>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== PROCESSING =====
|
||||
if (stage === 'processing') {
|
||||
return (
|
||||
<PageCenter>
|
||||
<GlassPaper sx={{ width: 'min(680px, 92vw)', p: 3 }}>
|
||||
<Glow />
|
||||
<Stack
|
||||
spacing={2.2}
|
||||
alignItems="center"
|
||||
sx={{ position: 'relative' }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: '50%',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
|
||||
<GradientTitle variant="h6" sx={{ textAlign: 'center' }}>
|
||||
{stepText}
|
||||
</GradientTitle>
|
||||
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 999,
|
||||
backgroundImage: GRADIENT,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography sx={{ fontSize: 12, opacity: 0.75, mt: 1 }}>
|
||||
{Math.round(progress)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.8,
|
||||
borderRadius: 999,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ opacity: 0.85, fontSize: 13 }}>
|
||||
{rubles.toLocaleString('ru-RU')} ₽
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.8,
|
||||
borderRadius: 999,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ opacity: 0.85, fontSize: 13 }}>
|
||||
{rubles.toLocaleString('ru-RU')} монет
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.8,
|
||||
borderRadius: 999,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ opacity: 0.85, fontSize: 13 }}>
|
||||
{methodLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
sx={{ fontSize: 12, opacity: 0.55, textAlign: 'center' }}
|
||||
>
|
||||
Пожалуйста, не закрывайте окно
|
||||
</Typography>
|
||||
</Stack>
|
||||
</GlassPaper>
|
||||
</PageCenter>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== FORM =====
|
||||
return (
|
||||
<PageCenter>
|
||||
<GlassPaper sx={{ width: 'min(680px, 92vw)', p: 3 }}>
|
||||
<Glow />
|
||||
<Stack spacing={2.2} sx={{ position: 'relative' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<GradientTitle variant="h5">Пополнение баланса</GradientTitle>
|
||||
<Typography sx={{ opacity: 0.75 }}>1 ₽ = 1 монета</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: '1.3fr 1fr' },
|
||||
gap: 2,
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<StyledTextField
|
||||
label="Сколько монет нужно?"
|
||||
value={coins}
|
||||
onChange={(e) => setCoins(Number(e.target.value))}
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
fullWidth
|
||||
sx={{'& .MuiFormLabel-root': {fontFamily: 'Benzin-Bold'}}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 20,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ opacity: 0.75, fontSize: 12 }}>
|
||||
Итого к оплате
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
fontSize: 26,
|
||||
fontWeight: 900,
|
||||
fontFamily: 'Benzin-Bold, sans-serif',
|
||||
}}
|
||||
>
|
||||
{rubles.toLocaleString('ru-RU')} ₽
|
||||
</Typography>
|
||||
<Typography sx={{ opacity: 0.65, fontSize: 12, mt: 0.5 }}>
|
||||
Начислим: {rubles.toLocaleString('ru-RU')} монет
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography sx={{ mb: 1, fontWeight: 700, opacity: 0.9 }}>
|
||||
Способ оплаты
|
||||
</Typography>
|
||||
|
||||
<StyledToggleButtonGroup
|
||||
value={method}
|
||||
exclusive
|
||||
onChange={(_, v) => v && setMethod(v)}
|
||||
fullWidth
|
||||
>
|
||||
<ToggleButton value="sbp">СБП</ToggleButton>
|
||||
<ToggleButton value="card">Карта</ToggleButton>
|
||||
<ToggleButton value="crypto">Crypto</ToggleButton>
|
||||
<ToggleButton value="other">Другое</ToggleButton>
|
||||
</StyledToggleButtonGroup>
|
||||
|
||||
<Typography sx={{ mt: 1, fontSize: 12, opacity: 0.55 }}>
|
||||
Выбрано: {methodLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<GradientButton
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handlePay}
|
||||
disabled={rubles <= 0}
|
||||
sx={{
|
||||
height: 52,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
Оплатить
|
||||
</GradientButton>
|
||||
</Stack>
|
||||
</GlassPaper>
|
||||
</PageCenter>
|
||||
);
|
||||
}
|
||||
813
src/renderer/pages/Inventory.tsx
Normal file
813
src/renderer/pages/Inventory.tsx
Normal file
@ -0,0 +1,813 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Typography, Grid, Button, Paper, FormControl, Select, MenuItem, InputLabel } from '@mui/material';
|
||||
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
import { translateServer } from '../utils/serverTranslator';
|
||||
import {
|
||||
fetchInventoryItems,
|
||||
withdrawInventoryItem,
|
||||
type InventoryRawItem,
|
||||
type InventoryItemsResponse,
|
||||
} from '../api';
|
||||
import CustomTooltip from '../components/Notifications/CustomTooltip';
|
||||
import { getPlayerServer } from '../utils/playerOnlineCheck';
|
||||
|
||||
const KNOWN_SERVER_IPS = [
|
||||
'minecraft.hub.popa-popa.ru',
|
||||
'minecraft.survival.popa-popa.ru',
|
||||
'minecraft.minigames.popa-popa.ru',
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'inventory_layout';
|
||||
|
||||
function stripMinecraftColors(text?: string | null): string {
|
||||
if (!text) return '';
|
||||
return text.replace(/§[0-9A-FK-ORa-fk-or]/g, '');
|
||||
}
|
||||
|
||||
const CARD_BG =
|
||||
'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)';
|
||||
|
||||
const CardFacePaperSx = {
|
||||
borderRadius: '1.2vw',
|
||||
background: CARD_BG,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
|
||||
color: 'white',
|
||||
} as const;
|
||||
|
||||
function readInventoryLayout(): any {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeInventoryLayout(next: any) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
function getLayout(username: string, serverIp: string): Record<string, number> {
|
||||
const inv = readInventoryLayout();
|
||||
return inv?.[username]?.[serverIp] ?? {};
|
||||
}
|
||||
|
||||
function setLayout(username: string, serverIp: string, layout: Record<string, number>) {
|
||||
const inv = readInventoryLayout();
|
||||
inv[username] ??= {};
|
||||
inv[username][serverIp] = layout;
|
||||
writeInventoryLayout(inv);
|
||||
}
|
||||
|
||||
function buildSlots(
|
||||
items: InventoryRawItem[],
|
||||
layout: Record<string, number>,
|
||||
size = 28,
|
||||
): (InventoryRawItem | null)[] {
|
||||
const slots: (InventoryRawItem | null)[] = Array.from({ length: size }, () => null);
|
||||
|
||||
const byId = new Map(items.map((it) => [it.id, it]));
|
||||
const used = new Set<string>();
|
||||
|
||||
// 1) ставим туда, куда сохранено
|
||||
for (const [id, idx] of Object.entries(layout)) {
|
||||
const i = Number(idx);
|
||||
const it = byId.get(id);
|
||||
if (!it) continue;
|
||||
if (Number.isFinite(i) && i >= 0 && i < size && !slots[i]) {
|
||||
slots[i] = it;
|
||||
used.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) остальные — в первые пустые
|
||||
for (const it of items) {
|
||||
if (used.has(it.id)) continue;
|
||||
const empty = slots.findIndex((x) => x === null);
|
||||
if (empty === -1) break;
|
||||
slots[empty] = it;
|
||||
used.add(it.id);
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
export default function Inventory() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [availableServers, setAvailableServers] = useState<string[]>([]);
|
||||
const [selectedServerIp, setSelectedServerIp] = useState<string>('');
|
||||
const [items, setItems] = useState<InventoryRawItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 28;
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [withdrawingIds, setWithdrawingIds] = useState<string[]>([]);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
const [isOnline, setIsOnline] = useState(false);
|
||||
const [playerServer, setPlayerServer] = useState<string | null>(null);
|
||||
const [checkingOnline, setCheckingOnline] = useState(false);
|
||||
|
||||
const [draggedItemId, setDraggedItemId] = useState<string | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
type SlotItem = InventoryRawItem | null;
|
||||
|
||||
const [slots, setSlots] = useState<SlotItem[]>(() => Array.from({ length: 28 }, () => null));
|
||||
const [draggedFromIndex, setDraggedFromIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMove = (e: MouseEvent) => {
|
||||
if (!draggedItemId) return;
|
||||
setDragPos({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
if (
|
||||
draggedItemId &&
|
||||
draggedFromIndex !== null &&
|
||||
dragOverIndex !== null &&
|
||||
draggedFromIndex !== dragOverIndex
|
||||
) {
|
||||
setSlots((prev) => {
|
||||
const next = [...prev];
|
||||
const moving = next[draggedFromIndex];
|
||||
if (!moving) return prev;
|
||||
|
||||
// ✅ swap или move в пустоту
|
||||
const target = next[dragOverIndex];
|
||||
next[dragOverIndex] = moving;
|
||||
next[draggedFromIndex] = target ?? null;
|
||||
|
||||
// ✅ сохраняем layout (позиции предметов)
|
||||
const layout: Record<string, number> = {};
|
||||
next.forEach((it, idx) => {
|
||||
if (it) layout[it.id] = idx;
|
||||
});
|
||||
|
||||
if (username && selectedServerIp) {
|
||||
setLayout(username, selectedServerIp, layout);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setDraggedItemId(null);
|
||||
setDraggedFromIndex(null);
|
||||
setDragOverIndex(null);
|
||||
setDragPos(null);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMove);
|
||||
window.addEventListener('mouseup', handleUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMove);
|
||||
window.removeEventListener('mouseup', handleUp);
|
||||
};
|
||||
}, [draggedItemId, dragOverIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
if (!savedConfig) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = JSON.parse(savedConfig);
|
||||
if (config?.username) setUsername(config.username);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const detectServersWithItems = async (u: string) => {
|
||||
const checks = await Promise.allSettled(
|
||||
KNOWN_SERVER_IPS.map(async (ip) => {
|
||||
const res = await fetchInventoryItems(u, ip, 1, 1);
|
||||
return { ip, has: (res.items || []).length > 0 || (res.total ?? 0) > 0 };
|
||||
}),
|
||||
);
|
||||
|
||||
return checks
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<{ ip: string; has: boolean }> => r.status === 'fulfilled',
|
||||
)
|
||||
.filter((r) => r.value.has)
|
||||
.map((r) => r.value.ip);
|
||||
};
|
||||
|
||||
const loadInventory = async (u: string, ip: string, p: number) => {
|
||||
const res: InventoryItemsResponse = await fetchInventoryItems(u, ip, p, limit);
|
||||
const list = res.items || [];
|
||||
|
||||
setItems(res.items || []);
|
||||
setTotal(res.total ?? 0);
|
||||
setPages(Math.max(1, Math.ceil((res.total ?? 0) / (res.limit ?? limit))));
|
||||
|
||||
const layout = getLayout(u, ip);
|
||||
setSlots(buildSlots(list, layout, 28));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const servers = await detectServersWithItems(username);
|
||||
if (cancelled) return;
|
||||
|
||||
setAvailableServers(servers);
|
||||
|
||||
const defaultIp = servers[0] || '';
|
||||
setSelectedServerIp(defaultIp);
|
||||
setPage(1);
|
||||
|
||||
if (defaultIp) {
|
||||
await loadInventory(username, defaultIp, 1);
|
||||
} else {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setPages(1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setAvailableServers([]);
|
||||
setSelectedServerIp('');
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setPages(1);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [username]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username || !selectedServerIp) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setPage(1);
|
||||
await loadInventory(username, selectedServerIp, 1);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setPages(1);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedServerIp]);
|
||||
|
||||
const withWithdrawing = async (id: string, fn: () => Promise<void>) => {
|
||||
setWithdrawingIds((prev) => [...prev, id]);
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
setWithdrawingIds((prev) => prev.filter((x) => x !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async (item: InventoryRawItem) => {
|
||||
if (!username || !selectedServerIp) return;
|
||||
|
||||
// сервер в UI может не совпадать — оставим защиту
|
||||
if (selectedServerIp !== item.server_ip) {
|
||||
alert('Ошибка! Вы не на том сервере для выдачи этого предмета.');
|
||||
return;
|
||||
}
|
||||
|
||||
await withWithdrawing(item.id, async () => {
|
||||
try {
|
||||
await withdrawInventoryItem({
|
||||
username,
|
||||
item_id: item.id,
|
||||
server_ip: selectedServerIp,
|
||||
});
|
||||
|
||||
setItems((prevItems) => prevItems.filter((prevItem) => prevItem.id !== item.id));
|
||||
|
||||
setSlots((prev) => {
|
||||
const next = prev.map((x) => (x?.id === item.id ? null : x));
|
||||
|
||||
const layout: Record<string, number> = {};
|
||||
next.forEach((it, idx) => {
|
||||
if (it) layout[it.id] = idx;
|
||||
});
|
||||
|
||||
setLayout(username, selectedServerIp, layout);
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Ошибка при выводе предмета:', e);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const checkPlayerStatus = async () => {
|
||||
if (!username) return;
|
||||
|
||||
setCheckingOnline(true);
|
||||
try {
|
||||
const res = await getPlayerServer(username);
|
||||
setIsOnline(!!res?.online);
|
||||
setPlayerServer(res?.server?.ip ?? null);
|
||||
} catch (e) {
|
||||
console.error('Ошибка проверки онлайна:', e);
|
||||
setIsOnline(false);
|
||||
setPlayerServer(null);
|
||||
} finally {
|
||||
setCheckingOnline(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
checkPlayerStatus();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [username]);
|
||||
|
||||
const headerServerName = selectedServerIp ? translateServer(`Server ${selectedServerIp}`) : '';
|
||||
|
||||
if (!username) {
|
||||
return (
|
||||
<Box sx={{ p: '2vw' }}>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.75)' }}>
|
||||
Не найдено имя игрока. Авторизуйтесь в лаунчере.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const canPrev = page > 1;
|
||||
const canNext = page < pages;
|
||||
|
||||
const handlePrev = async () => {
|
||||
if (!canPrev || !username || !selectedServerIp) return;
|
||||
const nextPage = page - 1;
|
||||
setPage(nextPage);
|
||||
setLoading(true);
|
||||
try {
|
||||
await loadInventory(username, selectedServerIp, nextPage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (!canNext || !username || !selectedServerIp) return;
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
setLoading(true);
|
||||
try {
|
||||
await loadInventory(username, selectedServerIp, nextPage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
px: '2.5vw',
|
||||
py: '2vw',
|
||||
gap: 2,
|
||||
mt: '12vh',
|
||||
}}
|
||||
>
|
||||
{/* ШАПКА + ПАГИНАЦИЯ */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-evenly',
|
||||
flexDirection: 'row-reverse',
|
||||
}}
|
||||
>
|
||||
{!!selectedServerIp && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!canPrev || loading}
|
||||
onClick={handlePrev}
|
||||
sx={{
|
||||
borderRadius: '2.5vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
}}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
Страница {page} / {pages} • Всего: {total}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!canNext || loading}
|
||||
onClick={handleNext}
|
||||
sx={{
|
||||
borderRadius: '2.5vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
}}
|
||||
>
|
||||
Вперёд
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{availableServers.length > 0 && (
|
||||
<FormControl size="small" sx={{ minWidth: 260 }}>
|
||||
<InputLabel
|
||||
id="inventory-server-label"
|
||||
sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.75)' }}
|
||||
>
|
||||
Сервер
|
||||
</InputLabel>
|
||||
|
||||
<Select
|
||||
labelId="inventory-server-label"
|
||||
label="Сервер"
|
||||
value={selectedServerIp}
|
||||
onChange={(e) => setSelectedServerIp(String(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)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{availableServers.map((ip) => (
|
||||
<MenuItem key={ip} value={ip}>
|
||||
{translateServer(`Server ${ip}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
Инвентарь {headerServerName ? `— ${headerServerName}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* GRID */}
|
||||
{loading ? (
|
||||
<FullScreenLoader fullScreen={false} message="Загрузка инвентаря..." />
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{Array.from({ length: 28 }).map((_, index) => {
|
||||
const item = slots[index];
|
||||
|
||||
// ПУСТАЯ ЯЧЕЙКА
|
||||
if (!item) {
|
||||
return (
|
||||
<Grid
|
||||
item
|
||||
xs={3}
|
||||
key={index}
|
||||
onMouseEnter={() => {
|
||||
if (draggedItemId) setDragOverIndex(index);
|
||||
}}
|
||||
sx={{
|
||||
outline:
|
||||
draggedItemId && dragOverIndex === index
|
||||
? '2px dashed rgba(255,255,255,0.4)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
...CardFacePaperSx,
|
||||
overflow: 'hidden',
|
||||
width: '12vw',
|
||||
height: '12vw',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
}}
|
||||
>
|
||||
<Typography>Пусто</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
const displayNameRaw =
|
||||
item.item_data?.meta?.display_name ?? item.item_data?.material ?? 'Предмет';
|
||||
const displayName = stripMinecraftColors(displayNameRaw);
|
||||
|
||||
const amount =
|
||||
(item as any)?.amount ??
|
||||
(item as any)?.item_data?.amount ??
|
||||
(item as any)?.item_data?.meta?.amount ??
|
||||
1;
|
||||
|
||||
const isHovered = hoveredId === item.id;
|
||||
const isWithdrawing = withdrawingIds.includes(item.id);
|
||||
|
||||
// ✅ проверка: игрок реально онлайн на нужном сервере
|
||||
const isOnRightServer = isOnline && playerServer === item.server_ip;
|
||||
const canWithdraw = isOnRightServer && !loading && !checkingOnline && !isWithdrawing;
|
||||
|
||||
const texture = item.item_data?.material
|
||||
? `https://cdn.minecraft.popa-popa.ru/textures/${item.item_data.material.toLowerCase()}.png`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Grid
|
||||
item
|
||||
xs={3}
|
||||
key={item.id}
|
||||
onMouseEnter={() => {
|
||||
if (draggedItemId) setDragOverIndex(index);
|
||||
}}
|
||||
sx={{
|
||||
outline:
|
||||
draggedItemId && dragOverIndex === index
|
||||
? '2px dashed rgba(255,255,255,0.4)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
perspective: '1200px',
|
||||
cursor: draggedItemId === item.id ? 'grabbing' : 'grab',
|
||||
opacity: draggedItemId === item.id ? 0.4 : 1,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredId(item.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button')) return;
|
||||
|
||||
e.preventDefault();
|
||||
setDraggedItemId(item.id);
|
||||
setDraggedFromIndex(index);
|
||||
setDragPos({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '12vw',
|
||||
height: '12vw', // фиксированная высота = Grid не дергается
|
||||
transformStyle: 'preserve-3d',
|
||||
transition: 'transform 0.5s cubic-bezier(0.4, 0.2, 0.2, 1)',
|
||||
transform: isHovered ? 'rotateY(180deg)' : 'rotateY(0deg)',
|
||||
}}
|
||||
>
|
||||
{/* FRONT */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
...CardFacePaperSx,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backfaceVisibility: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={texture}
|
||||
sx={{
|
||||
width: '5vw',
|
||||
height: '5vw',
|
||||
objectFit: 'contain',
|
||||
imageRendering: 'pixelated',
|
||||
userSelect: 'none',
|
||||
transition: 'transform 0.25s ease',
|
||||
transform: isHovered ? 'scale(1.05)' : 'scale(1)',
|
||||
}}
|
||||
draggable={false}
|
||||
alt={displayName}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* BACK */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
...CardFacePaperSx,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: 'rotateY(180deg)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: '0.8vw',
|
||||
px: '1.1vw',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '0.8vw',
|
||||
lineHeight: 1.2,
|
||||
textAlign: 'center',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.7vw',
|
||||
textAlign: 'center',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
}}
|
||||
>
|
||||
Кол-во: {amount}
|
||||
</Typography>
|
||||
|
||||
{!isOnRightServer ? (
|
||||
<CustomTooltip
|
||||
essential
|
||||
title={
|
||||
!isOnline
|
||||
? 'Вы должны быть онлайн на сервере'
|
||||
: `Перейдите на сервер ${item.server_ip}`
|
||||
}
|
||||
placement="top"
|
||||
arrow
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
disabled
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.8vw',
|
||||
borderRadius: '2vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
}}
|
||||
>
|
||||
Выдать
|
||||
</Button>
|
||||
</span>
|
||||
</CustomTooltip>
|
||||
) : (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
disabled={!canWithdraw}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleWithdraw(item);
|
||||
}}
|
||||
sx={{
|
||||
fontSize: '0.8vw',
|
||||
borderRadius: '2vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{isWithdrawing ? 'Выдача...' : 'Выдать'}
|
||||
</Button>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
{draggedItemId && dragPos && (() => {
|
||||
const draggedItem = items.find(i => i.id === draggedItemId);
|
||||
if (!draggedItem) return null;
|
||||
|
||||
const texture = `https://cdn.minecraft.popa-popa.ru/textures/${draggedItem.item_data.material.toLowerCase()}.png`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
left: dragPos.x,
|
||||
top: dragPos.y,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
//width: '12vw',
|
||||
//height: '12vw',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9999,
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
...CardFacePaperSx,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '12vw',
|
||||
height: '12vw',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={texture}
|
||||
sx={{
|
||||
width: '5vw',
|
||||
height: '5vw',
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,4 @@
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Snackbar,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import { Box, Typography, Button, LinearProgress } from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import ServerStatus from '../components/ServerStatus/ServerStatus';
|
||||
@ -13,6 +6,12 @@ import PopaPopa from '../components/popa-popa';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import React from 'react';
|
||||
import SettingsModal from '../components/Settings/SettingsModal';
|
||||
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||
import {
|
||||
isNotificationsEnabled,
|
||||
getNotifPositionFromSettings,
|
||||
} from '../utils/notifications';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -30,7 +29,6 @@ declare global {
|
||||
interface LaunchPageProps {
|
||||
onLaunchPage?: () => void;
|
||||
launchOptions?: {
|
||||
// Делаем опциональным
|
||||
downloadUrl: string;
|
||||
apiReleaseUrl: string;
|
||||
versionFileName: string;
|
||||
@ -39,6 +37,8 @@ interface LaunchPageProps {
|
||||
baseVersion: string;
|
||||
serverIp: string;
|
||||
fabricVersion: string;
|
||||
neoForgeVersion?: string;
|
||||
loaderType?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@ -49,6 +49,7 @@ const LaunchPage = ({
|
||||
const navigate = useNavigate();
|
||||
const { versionId } = useParams();
|
||||
const [versionConfig, setVersionConfig] = useState<any>(null);
|
||||
const [fullVersionConfig, setFullVersionConfig] = useState<any>(null); // Полная конфигурация из Gist
|
||||
|
||||
// Начальное состояние должно быть пустым или с минимальными значениями
|
||||
const [config, setConfig] = useState<{
|
||||
@ -59,17 +60,23 @@ const LaunchPage = ({
|
||||
preserveFiles: [],
|
||||
});
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [buffer, setBuffer] = useState(10);
|
||||
const [installStatus, setInstallStatus] = useState('');
|
||||
const [notification, setNotification] = useState<{
|
||||
open: boolean;
|
||||
message: string;
|
||||
severity: 'success' | 'error' | 'info';
|
||||
}>({ open: false, message: '', severity: 'info' });
|
||||
const [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 [installStep, setInstallStep] = useState('');
|
||||
const [installMessage, setInstallMessage] = useState('');
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [isGameRunning, setIsGameRunning] = useState(false);
|
||||
const handleOpen = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
@ -79,10 +86,10 @@ const LaunchPage = ({
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
const progressListener = (...args: unknown[]) => {
|
||||
const progress = args[0] as number;
|
||||
setDownloadProgress(progress);
|
||||
setBuffer(Math.min(progress + 10, 100));
|
||||
const overallProgressListener = (...args: unknown[]) => {
|
||||
const value = args[0] as number; // 0..100
|
||||
setProgress(value);
|
||||
setBuffer(Math.min(value + 10, 100));
|
||||
};
|
||||
|
||||
const statusListener = (...args: unknown[]) => {
|
||||
@ -91,21 +98,92 @@ const LaunchPage = ({
|
||||
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 raw = localStorage.getItem('pending_launch_context');
|
||||
if (!raw) return;
|
||||
|
||||
const context = JSON.parse(raw);
|
||||
|
||||
localStorage.setItem(
|
||||
'last_launched_version',
|
||||
JSON.stringify({
|
||||
...context,
|
||||
launchedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
localStorage.removeItem('pending_launch_context');
|
||||
};
|
||||
|
||||
const minecraftStoppedListener = () => {
|
||||
setIsGameRunning(false);
|
||||
};
|
||||
|
||||
window.electron.ipcRenderer.on('overall-progress', overallProgressListener);
|
||||
window.electron.ipcRenderer.on('minecraft-error', minecraftErrorListener);
|
||||
window.electron.ipcRenderer.on('installation-status', statusListener);
|
||||
window.electron.ipcRenderer.on(
|
||||
'minecraft-started',
|
||||
minecraftStartedListener,
|
||||
);
|
||||
window.electron.ipcRenderer.on(
|
||||
'minecraft-stopped',
|
||||
minecraftStoppedListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
// Удаляем только конкретных слушателей, а не всех
|
||||
// Это безопаснее, чем removeAllListeners
|
||||
const cleanup = window.electron.ipcRenderer.on;
|
||||
if (typeof cleanup === 'function') {
|
||||
cleanup('download-progress', progressListener);
|
||||
cleanup('installation-status', statusListener);
|
||||
cleanup('minecraft-error', statusListener);
|
||||
cleanup('overall-progress', overallProgressListener);
|
||||
}
|
||||
// Удаляем использование removeAllListeners
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
// Функция для загрузки полной конфигурации версии из Gist
|
||||
const fetchFullVersionConfig = async (): Promise<any> => {
|
||||
if (!versionId) return null;
|
||||
|
||||
try {
|
||||
// Загружаем весь список версий из Gist
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'get-available-versions',
|
||||
{},
|
||||
);
|
||||
|
||||
if (result.success && result.versions) {
|
||||
// Находим нужную версию по ID
|
||||
const version = result.versions.find((v: any) => v.id === versionId);
|
||||
return version || null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке полной конфигурации:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVersionConfig = async () => {
|
||||
if (!versionId) return;
|
||||
@ -115,49 +193,68 @@ const LaunchPage = ({
|
||||
const savedConfig = localStorage.getItem('selected_version_config');
|
||||
if (savedConfig) {
|
||||
const parsedConfig = JSON.parse(savedConfig);
|
||||
setVersionConfig(parsedConfig);
|
||||
|
||||
// Устанавливаем значения памяти и preserveFiles из конфигурации
|
||||
setConfig({
|
||||
memory: parsedConfig.memory || 4096,
|
||||
preserveFiles: parsedConfig.preserveFiles || [],
|
||||
});
|
||||
// Если конфиг пустой — считаем, что он невалидный и идём по IPC-ветке
|
||||
if (Object.keys(parsedConfig).length > 0) {
|
||||
setVersionConfig(parsedConfig);
|
||||
setFullVersionConfig(parsedConfig);
|
||||
|
||||
// Очищаем localStorage
|
||||
localStorage.removeItem('selected_version_config');
|
||||
return;
|
||||
setConfig({
|
||||
memory: parsedConfig.memory || 4096,
|
||||
preserveFiles: parsedConfig.preserveFiles || [],
|
||||
});
|
||||
|
||||
localStorage.removeItem('selected_version_config');
|
||||
return;
|
||||
} else {
|
||||
localStorage.removeItem('selected_version_config');
|
||||
}
|
||||
}
|
||||
|
||||
// Если нет в localStorage, запрашиваем с сервера
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'get-version-config',
|
||||
{ versionId },
|
||||
);
|
||||
// Загружаем полную конфигурацию из Gist
|
||||
const fullConfig = await fetchFullVersionConfig();
|
||||
if (fullConfig) {
|
||||
setFullVersionConfig(fullConfig);
|
||||
|
||||
// Сохраняем только config часть для совместимости
|
||||
setVersionConfig(fullConfig.config || {});
|
||||
|
||||
if (result.success) {
|
||||
setVersionConfig(result.config);
|
||||
setConfig({
|
||||
memory: result.config.memory || 4096,
|
||||
preserveFiles: result.config.preserveFiles || [],
|
||||
memory: fullConfig.config?.memory || 4096,
|
||||
preserveFiles: fullConfig.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 || [],
|
||||
});
|
||||
// Если не удалось получить конфигурацию из Gist, используем IPC
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'get-version-config',
|
||||
{ versionId },
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setVersionConfig(result.config);
|
||||
setConfig({
|
||||
memory: result.config.memory || 4096,
|
||||
preserveFiles: result.config.preserveFiles || [],
|
||||
});
|
||||
} else {
|
||||
// Если не удалось получить конфигурацию, используем значения по умолчанию
|
||||
const defaultConfig = {
|
||||
downloadUrl: '',
|
||||
apiReleaseUrl: '',
|
||||
versionFileName: `${versionId}_version.txt`,
|
||||
packName: versionId || 'Comfort',
|
||||
memory: 4096,
|
||||
baseVersion: '1.21.4',
|
||||
serverIp: 'popa-popa.ru',
|
||||
fabricVersion: '0.16.14',
|
||||
preserveFiles: ['popa-launcher-config.json'],
|
||||
};
|
||||
setVersionConfig(defaultConfig);
|
||||
setConfig({
|
||||
memory: defaultConfig.memory,
|
||||
preserveFiles: defaultConfig.preserveFiles || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении настроек версии:', error);
|
||||
@ -185,32 +282,66 @@ const LaunchPage = ({
|
||||
}, [versionId]);
|
||||
|
||||
const showNotification = (
|
||||
message: string,
|
||||
severity: 'success' | 'error' | 'info',
|
||||
message: React.ReactNode,
|
||||
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
|
||||
position: NotificationPosition = getNotifPositionFromSettings(),
|
||||
) => {
|
||||
setNotification({ open: true, message, severity });
|
||||
};
|
||||
|
||||
const handleCloseNotification = () => {
|
||||
setNotification({ ...notification, open: false });
|
||||
if (!isNotificationsEnabled()) return;
|
||||
setNotifMsg(message);
|
||||
setNotifSeverity(severity);
|
||||
setNotifPos(position);
|
||||
setNotifOpen(true);
|
||||
};
|
||||
|
||||
// Функция для запуска игры с настройками выбранной версии
|
||||
const handleLaunchMinecraft = async () => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
setDownloadProgress(0);
|
||||
setBuffer(10);
|
||||
|
||||
// Используем настройки выбранной версии или дефолтные
|
||||
const currentConfig = versionConfig || {
|
||||
packName: versionId || 'Comfort',
|
||||
memory: 4096,
|
||||
baseVersion: '1.21.4',
|
||||
serverIp: 'popa-popa.ru',
|
||||
fabricVersion: '0.16.14',
|
||||
preserveFiles: [],
|
||||
};
|
||||
if (isGameRunning) {
|
||||
showNotification('Minecraft уже запущен', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем полную конфигурацию, если еще не загружена
|
||||
if (!fullVersionConfig) {
|
||||
const loadedConfig = await fetchFullVersionConfig();
|
||||
if (loadedConfig) {
|
||||
setFullVersionConfig(loadedConfig);
|
||||
setVersionConfig(loadedConfig.config || {});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('fullVersionConfig:', fullVersionConfig);
|
||||
console.log('versionFromGist:', fullVersionConfig?.version);
|
||||
|
||||
// Используем настройки из Gist или дефолтные
|
||||
const currentConfig = fullVersionConfig?.config ||
|
||||
versionConfig || {
|
||||
packName: versionId || 'Comfort',
|
||||
memory: 4096,
|
||||
baseVersion: '1.21.4',
|
||||
serverIp: 'popa-popa.ru',
|
||||
fabricVersion: '0.16.14',
|
||||
neoForgeVersion: null,
|
||||
loaderType: 'fabric',
|
||||
preserveFiles: [],
|
||||
};
|
||||
|
||||
// Получаем версию для запуска из Gist
|
||||
let versionFromGist = fullVersionConfig?.version || null;
|
||||
console.log('versionFromGist before override:', versionFromGist);
|
||||
|
||||
// Если версия из Gist пустая, используем логику по умолчанию
|
||||
if (
|
||||
!versionFromGist &&
|
||||
currentConfig.loaderType === 'neoforge' &&
|
||||
currentConfig.neoForgeVersion
|
||||
) {
|
||||
versionFromGist = `neoforge-${currentConfig.neoForgeVersion}`;
|
||||
console.log('Overriding versionFromGist to:', versionFromGist);
|
||||
}
|
||||
|
||||
// Проверяем, является ли это ванильной версией
|
||||
const isVanillaVersion =
|
||||
@ -254,7 +385,8 @@ const LaunchPage = ({
|
||||
localStorage.getItem('launcher_config') || '{}',
|
||||
);
|
||||
|
||||
const options = {
|
||||
// Формируем полные опции для запуска
|
||||
const options: any = {
|
||||
accessToken: savedConfig.accessToken,
|
||||
uuid: savedConfig.uuid,
|
||||
username: savedConfig.username,
|
||||
@ -263,11 +395,35 @@ const LaunchPage = ({
|
||||
packName: versionId || currentConfig.packName,
|
||||
serverIp: currentConfig.serverIp,
|
||||
fabricVersion: currentConfig.fabricVersion,
|
||||
// Для ванильной версии устанавливаем флаг
|
||||
neoForgeVersion: currentConfig.neoForgeVersion,
|
||||
loaderType: currentConfig.loaderType || 'fabric',
|
||||
isVanillaVersion: isVanillaVersion,
|
||||
versionToLaunchOverride: isVanillaVersion ? versionId : undefined,
|
||||
versionToLaunchOverride:
|
||||
versionFromGist || (isVanillaVersion ? versionId : undefined),
|
||||
// Передаем Gist URL для загрузки конфигурации в процессе запуска
|
||||
gistUrl:
|
||||
'https://gist.githubusercontent.com/DIKER0K/06cd12fb3a4d08b1f0f8c763a7d05e06/raw/versions.json',
|
||||
};
|
||||
|
||||
const launchContext = {
|
||||
versionId,
|
||||
packName: versionId || currentConfig.packName,
|
||||
baseVersion: currentConfig.baseVersion,
|
||||
fabricVersion: currentConfig.fabricVersion,
|
||||
neoForgeVersion: currentConfig.neoForgeVersion,
|
||||
loaderType: currentConfig.loaderType,
|
||||
serverIp: currentConfig.serverIp,
|
||||
isVanillaVersion,
|
||||
versionToLaunchOverride:
|
||||
versionFromGist || (isVanillaVersion ? versionId : undefined),
|
||||
memory: config.memory,
|
||||
};
|
||||
|
||||
localStorage.setItem(
|
||||
'pending_launch_context',
|
||||
JSON.stringify(launchContext),
|
||||
);
|
||||
|
||||
const launchResult = await window.electron.ipcRenderer.invoke(
|
||||
'launch-minecraft',
|
||||
options,
|
||||
@ -275,8 +431,10 @@ const LaunchPage = ({
|
||||
|
||||
if (launchResult?.success) {
|
||||
showNotification('Minecraft успешно запущен!', 'success');
|
||||
} else if (launchResult?.error) {
|
||||
showNotification(`Ошибка запуска: ${launchResult.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Error:', error);
|
||||
showNotification(`Ошибка: ${error.message}`, 'error');
|
||||
} finally {
|
||||
@ -284,6 +442,34 @@ const LaunchPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
localStorage.removeItem('pending_launch_context');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleStopMinecraft = async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('stop-minecraft');
|
||||
|
||||
if (result?.success) {
|
||||
showNotification('Minecraft остановлен', 'info');
|
||||
setIsGameRunning(false);
|
||||
} else if (result?.error) {
|
||||
showNotification(
|
||||
`Не удалось остановить Minecraft: ${result.error}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка при остановке Minecraft:', error);
|
||||
showNotification(
|
||||
`Ошибка при остановке Minecraft: ${error.message || String(error)}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для сохранения настроек
|
||||
const savePackConfig = async () => {
|
||||
try {
|
||||
@ -297,9 +483,6 @@ const LaunchPage = ({
|
||||
config: configToSave,
|
||||
});
|
||||
|
||||
// Обновляем launchOptions
|
||||
launchOptions.memory = config.memory;
|
||||
|
||||
showNotification('Настройки сохранены', 'success');
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении настроек:', error);
|
||||
@ -349,15 +532,40 @@ const LaunchPage = ({
|
||||
<Box sx={{ width: '100%', mr: 1 }}>
|
||||
<LinearProgress
|
||||
variant="buffer"
|
||||
value={downloadProgress}
|
||||
value={progress}
|
||||
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 sx={{ minWidth: 35 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'white' }}
|
||||
>{`${Math.round(downloadProgress)}%`}</Typography>
|
||||
>{`${Math.round(progress)}%`}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@ -373,16 +581,43 @@ const LaunchPage = ({
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleLaunchMinecraft}
|
||||
onClick={
|
||||
isGameRunning ? handleStopMinecraft : handleLaunchMinecraft
|
||||
}
|
||||
sx={{
|
||||
flexGrow: 1, // занимает всё свободное место
|
||||
width: 'auto', // ширина подстраивается
|
||||
flexGrow: 1,
|
||||
width: 'auto',
|
||||
borderRadius: '3vw',
|
||||
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>
|
||||
|
||||
{/* Вторая кнопка — квадратная, фиксированного размера (ширина = высоте) */}
|
||||
@ -396,6 +631,13 @@ const LaunchPage = ({
|
||||
minHeight: 'unset',
|
||||
minWidth: 'unset',
|
||||
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}
|
||||
>
|
||||
@ -404,19 +646,14 @@ const LaunchPage = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={notification.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleCloseNotification}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseNotification}
|
||||
severity={notification.severity}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{notification.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<CustomNotification
|
||||
open={notifOpen}
|
||||
message={notifMsg}
|
||||
severity={notifSeverity}
|
||||
position={notifPos}
|
||||
onClose={() => setNotifOpen(false)}
|
||||
autoHideDuration={2500}
|
||||
/>
|
||||
|
||||
<SettingsModal
|
||||
open={open}
|
||||
|
||||
@ -1,107 +1,887 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
Stack,
|
||||
Divider,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import useAuth from '../hooks/useAuth';
|
||||
import AuthForm from '../components/Login/AuthForm';
|
||||
import MemorySlider from '../components/Login/MemorySlider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PopaPopa from '../components/popa-popa';
|
||||
import useConfig from '../hooks/useConfig';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
import { styled, alpha, keyframes } from '@mui/material/styles';
|
||||
|
||||
const Login = () => {
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
import KeyIcon from '@mui/icons-material/Key';
|
||||
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
|
||||
import PersonAddAlt1RoundedIcon from '@mui/icons-material/PersonAddAlt1Rounded';
|
||||
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
|
||||
|
||||
import React from 'react';
|
||||
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||
import {
|
||||
isNotificationsEnabled,
|
||||
getNotifPositionFromSettings,
|
||||
} from '../utils/notifications';
|
||||
|
||||
// как в registration
|
||||
import QRCodeStyling from 'qr-code-styling';
|
||||
import popalogo from '../../../assets/icons/popa-popa.svg';
|
||||
import GradientTextField from '../components/GradientTextField';
|
||||
|
||||
// твои API методы
|
||||
import { qrInit, qrStatus } from '../api';
|
||||
|
||||
import { loadPending } from '../utils/pendingVerification';
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess?: (username: string) => void;
|
||||
}
|
||||
|
||||
const glowPulse = keyframes`
|
||||
0% { transform: translate3d(0,0,0) scale(1); opacity: .85; filter: saturate(1.0); }
|
||||
50% { transform: translate3d(0,-6px,0) scale(1.02); opacity: 1; filter: saturate(1.15); }
|
||||
100% { transform: translate3d(0,0,0) scale(1); opacity: .85; filter: saturate(1.0); }
|
||||
`;
|
||||
|
||||
const borderShimmer = keyframes`
|
||||
0% { background-position: 0% 50%; opacity: .35; }
|
||||
50% { background-position: 100% 50%; opacity: .55; }
|
||||
100% { background-position: 0% 50%; opacity: .35; }
|
||||
`;
|
||||
|
||||
const GRADIENT =
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||
|
||||
const GlassPaper = styled(Paper)(() => ({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 28,
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
|
||||
}));
|
||||
|
||||
const Glow = styled('div')(() => ({
|
||||
position: 'absolute',
|
||||
inset: -2,
|
||||
background:
|
||||
'radial-gradient(800px 300px at 20% 10%, rgba(242,113,33,0.22), transparent 60%),' +
|
||||
'radial-gradient(800px 300px at 80% 0%, rgba(233,64,205,0.18), transparent 55%),' +
|
||||
'radial-gradient(900px 420px at 50% 110%, rgba(138,35,135,0.20), transparent 60%)',
|
||||
pointerEvents: 'none',
|
||||
animation: `${glowPulse} 6s ease-in-out infinite`,
|
||||
}));
|
||||
|
||||
const GradientTitle = styled(Typography)(() => ({
|
||||
fontWeight: 900,
|
||||
backgroundImage:
|
||||
'linear-gradient(136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
fontFamily: 'Benzin-Bold, sans-serif',
|
||||
}));
|
||||
|
||||
const GradientButton = styled(Button)(() => ({
|
||||
background: GRADIENT,
|
||||
fontFamily: 'Benzin-Bold, sans-serif',
|
||||
borderRadius: 999,
|
||||
textTransform: 'none',
|
||||
transition: 'transform 0.25s ease, filter 0.25s ease, box-shadow 0.25s ease',
|
||||
boxShadow: '0 12px 30px rgba(0,0,0,0.35)',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.04)',
|
||||
filter: 'brightness(1.06)',
|
||||
boxShadow: '0 16px 42px rgba(0,0,0,0.48)',
|
||||
background: GRADIENT,
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
}));
|
||||
|
||||
const Segmented = styled(ToggleButtonGroup)(() => ({
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
'& .MuiToggleButton-root': {
|
||||
border: 'none',
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
fontFamily: 'Benzin-Bold, sans-serif',
|
||||
letterSpacing: '0.02em',
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
paddingLeft: 18,
|
||||
paddingRight: 18,
|
||||
transition: 'transform 0.2s ease, background 0.2s ease, color 0.2s ease',
|
||||
},
|
||||
'& .MuiToggleButton-root:hover': {
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
transform: 'scale(1.02)',
|
||||
},
|
||||
'& .MuiToggleButton-root.Mui-selected': {
|
||||
color: '#fff',
|
||||
background: GRADIENT,
|
||||
},
|
||||
'& .MuiToggleButton-root.Mui-selected:hover': {
|
||||
background: GRADIENT,
|
||||
},
|
||||
}));
|
||||
|
||||
const Login = ({ onLoginSuccess }: LoginProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { config, setConfig, saveConfig, handleInputChange } = useConfig();
|
||||
const { status, validateSession, refreshSession, authenticateWithElyBy } =
|
||||
useAuth();
|
||||
const { config, saveConfig, handleInputChange } = useConfig();
|
||||
const auth = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const authorization = async () => {
|
||||
console.log('Начинаем процесс авторизации...');
|
||||
// ===== UI mode: по умолчанию QR, парольная форма показывается по кнопке =====
|
||||
const [showPasswordLogin, setShowPasswordLogin] = useState(false);
|
||||
|
||||
// ===== QR =====
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
const [qrUrl, setQrUrl] = useState<string>('');
|
||||
const qrRef = useRef<HTMLDivElement | null>(null);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
const [qrState, setQrState] = useState<'idle' | 'ready' | 'polling' | 'expired'>('idle');
|
||||
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
// хранит один инстанс QRCodeStyling
|
||||
const qrInstanceRef = useRef<QRCodeStyling | null>(null);
|
||||
|
||||
const deviceId = useMemo(() => {
|
||||
const key = 'qr_device_id';
|
||||
const existing = localStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
const v = (
|
||||
crypto?.randomUUID?.() ?? `${Date.now()}_${Math.random()}`
|
||||
).toString();
|
||||
localStorage.setItem(key, v);
|
||||
return v;
|
||||
}, []);
|
||||
|
||||
// ===== Notifications =====
|
||||
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',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const list = loadPending();
|
||||
setPendingCount(list.length);
|
||||
}, []);
|
||||
|
||||
const handleContinuePending = () => {
|
||||
const list = loadPending();
|
||||
if (!list.length) return;
|
||||
|
||||
const last = list[0];
|
||||
|
||||
// чтобы Registration подхватил и сразу открыл verification
|
||||
// можно также записать в launcher_config — удобно
|
||||
saveConfig({ username: last.username, password: last.password ?? '' });
|
||||
|
||||
navigate('/registration', { replace: true });
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const stopQrPolling = () => {
|
||||
if (pollTimerRef.current) {
|
||||
window.clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => stopQrPolling();
|
||||
}, []);
|
||||
|
||||
// создаём QR инстанс с теми же настройками, что в registration
|
||||
useEffect(() => {
|
||||
if (!qrInstanceRef.current) {
|
||||
qrInstanceRef.current = 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// аппендим QR в контейнер, когда мы на QR-экране
|
||||
useEffect(() => {
|
||||
if (showPasswordLogin) return;
|
||||
if (!qrRef.current) return;
|
||||
if (!qrInstanceRef.current) return;
|
||||
|
||||
while (qrRef.current.firstChild) {
|
||||
qrRef.current.removeChild(qrRef.current.firstChild);
|
||||
}
|
||||
|
||||
qrInstanceRef.current.append(qrRef.current);
|
||||
}, [showPasswordLogin]);
|
||||
|
||||
// при изменении URL обновляем data в QR
|
||||
useEffect(() => {
|
||||
if (!qrInstanceRef.current) return;
|
||||
if (!qrUrl) return;
|
||||
|
||||
qrInstanceRef.current.update({ data: qrUrl });
|
||||
}, [qrUrl]);
|
||||
|
||||
const startQrLogin = async () => {
|
||||
setQrLoading(true);
|
||||
setQrState('idle');
|
||||
setQrUrl('');
|
||||
stopQrPolling();
|
||||
|
||||
try {
|
||||
const init = await qrInit(deviceId);
|
||||
setQrUrl(init.qr_url);
|
||||
setQrState('ready');
|
||||
|
||||
setQrState('polling');
|
||||
pollTimerRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await qrStatus(init.token, deviceId);
|
||||
|
||||
if (res.status === 'ok') {
|
||||
stopQrPolling();
|
||||
|
||||
const session = {
|
||||
accessToken: res.accessToken,
|
||||
clientToken: res.clientToken,
|
||||
selectedProfile: res.selectedProfile,
|
||||
};
|
||||
|
||||
await auth.applySession(session as any, saveConfig);
|
||||
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(res.selectedProfile?.name ?? config.username);
|
||||
}
|
||||
|
||||
showNotification('Успешный вход через QR', 'success');
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 'expired') {
|
||||
stopQrPolling();
|
||||
setQrState('expired');
|
||||
showNotification('QR-код истёк. Нажми “Обновить QR”.', 'warning');
|
||||
}
|
||||
} catch {
|
||||
// transient ошибки игнорим, следующий тик повторит
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e: any) {
|
||||
showNotification(e?.message || 'Не удалось запустить QR-вход', 'error');
|
||||
} finally {
|
||||
setQrLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// автозапуск QR при открытии страницы
|
||||
useEffect(() => {
|
||||
startQrLogin();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const goToPasswordLogin = () => {
|
||||
stopQrPolling();
|
||||
setShowPasswordLogin(true);
|
||||
};
|
||||
|
||||
const backToQr = () => {
|
||||
setShowPasswordLogin(false);
|
||||
startQrLogin(); // перегенерим свежий QR при возврате
|
||||
};
|
||||
|
||||
// ===== Password login =====
|
||||
const mapAuthErrorToMessage = (error: any): string => {
|
||||
const raw = error?.message ? String(error.message) : String(error);
|
||||
|
||||
if (raw.includes('Failed to fetch') || raw.includes('NetworkError')) {
|
||||
return 'Сервер недоступен';
|
||||
}
|
||||
|
||||
const jsonStart = raw.indexOf('{');
|
||||
if (jsonStart !== -1) {
|
||||
const jsonStr = raw.slice(jsonStart);
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
const detail = data?.detail;
|
||||
|
||||
if (detail === 'Invalid credentials')
|
||||
return 'Неверный логин или пароль';
|
||||
if (detail === 'User not verified') return 'Аккаунт не подтверждён';
|
||||
|
||||
if (typeof detail === 'string' && detail.trim()) return detail;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (raw.includes('Invalid credentials')) return 'Неверный логин или пароль';
|
||||
if (raw.includes('User not verified')) return 'Аккаунт не подтверждён';
|
||||
|
||||
return raw.startsWith('Ошибка') ? raw : `Ошибка: ${raw}`;
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!config.username.trim()) {
|
||||
console.log('Ошибка: не указан никнейм');
|
||||
alert('Введите никнейм!');
|
||||
showNotification('Введите никнейм!', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.password) {
|
||||
showNotification('Введите пароль!', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Проверяем, есть ли сохранённый токен
|
||||
if (config.accessToken && config.clientToken) {
|
||||
console.log('Проверка валидности существующего токена...');
|
||||
const isValid = await validateSession(config.accessToken);
|
||||
const isValid = await auth.validateSession(config.accessToken);
|
||||
|
||||
if (!isValid) {
|
||||
console.log('Токен недействителен, пытаемся обновить...');
|
||||
const refreshedSession = await refreshSession(
|
||||
const refreshed = await auth.refreshSession(
|
||||
config.accessToken,
|
||||
config.clientToken,
|
||||
);
|
||||
|
||||
if (!refreshedSession) {
|
||||
console.log(
|
||||
'Не удалось обновить токен, требуется новая авторизация',
|
||||
if (!refreshed) {
|
||||
await auth.authenticateUser(
|
||||
config.username,
|
||||
config.password,
|
||||
saveConfig,
|
||||
);
|
||||
// Очищаем недействительные токены
|
||||
saveConfig({
|
||||
accessToken: '',
|
||||
clientToken: '',
|
||||
});
|
||||
|
||||
// Пытаемся выполнить новую авторизацию
|
||||
if (config.password) {
|
||||
const newSession = await authenticateWithElyBy(
|
||||
config.username,
|
||||
config.password,
|
||||
saveConfig,
|
||||
);
|
||||
if (!newSession) {
|
||||
console.log('Авторизация не удалась');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log('Требуется ввод пароля для новой авторизации');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('Токен действителен');
|
||||
}
|
||||
} else {
|
||||
console.log('Токен отсутствует, выполняем авторизацию...');
|
||||
// Проверяем наличие пароля
|
||||
if (!config.password) {
|
||||
console.log('Ошибка: не указан пароль');
|
||||
alert('Введите пароль!');
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await authenticateWithElyBy(
|
||||
await auth.authenticateUser(
|
||||
config.username,
|
||||
config.password,
|
||||
saveConfig,
|
||||
);
|
||||
if (!session) {
|
||||
console.log('Авторизация не удалась');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Авторизация успешно завершена');
|
||||
if (onLoginSuccess) onLoginSuccess(config.username);
|
||||
showNotification('Успешный вход', 'success');
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
console.log(`ОШИБКА при авторизации: ${error.message}`);
|
||||
// Очищаем недействительные токены при ошибке
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка авторизации:', error);
|
||||
|
||||
const msg = mapAuthErrorToMessage(error);
|
||||
showNotification(msg, 'error');
|
||||
|
||||
saveConfig({
|
||||
accessToken: '',
|
||||
clientToken: '',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const gradientTextSx = {
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1.5vw',
|
||||
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 },
|
||||
} as const;
|
||||
|
||||
const primaryButtonSx = {
|
||||
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)' },
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PopaPopa />
|
||||
<AuthForm
|
||||
config={config}
|
||||
handleInputChange={handleInputChange}
|
||||
onLogin={authorization}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
minHeight: 'calc(100vh - 8vh)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
px: '2vw',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<FullScreenLoader message="Входим..." />
|
||||
) : (
|
||||
<GlassPaper
|
||||
sx={{
|
||||
// width: 'min(64vw, 980px)',
|
||||
borderRadius: '2.2vw',
|
||||
}}
|
||||
>
|
||||
<Glow />
|
||||
|
||||
<Box sx={{ position: 'relative', p: '2.2vw' }}>
|
||||
{/* header */}
|
||||
<Stack alignItems="center">
|
||||
<PopaPopa />
|
||||
|
||||
{!showPasswordLogin ? (
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ my: '1vw' }}>
|
||||
<GradientTitle sx={{ fontSize: 'clamp(16px, 1.2vw, 18px)' }}>
|
||||
Вход через Telegram
|
||||
</GradientTitle>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.2,
|
||||
py: 0.45,
|
||||
borderRadius: 999,
|
||||
fontSize: 'clamp(10px, 0.75vw, 12px)',
|
||||
fontWeight: 900,
|
||||
letterSpacing: '0.03em',
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
background:
|
||||
qrState === 'polling'
|
||||
? 'rgba(255,255,255,0.06)'
|
||||
: qrState === 'expired'
|
||||
? 'rgba(255,60,60,0.12)'
|
||||
: 'rgba(255,255,255,0.06)',
|
||||
}}
|
||||
>
|
||||
{qrState === 'polling' ? 'ожидание' : qrState === 'expired' ? 'истёк' : 'готов'}
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ my: '1vw' }}>
|
||||
<GradientTitle sx={{ fontSize: 'clamp(16px, 1.2vw, 18px)' }}>
|
||||
Вход по логину и паролю
|
||||
</GradientTitle>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* segmented */}
|
||||
{pendingCount > 0 ? (
|
||||
<Box sx={{ mb: '1vw' }}>
|
||||
{/* <Button
|
||||
fullWidth
|
||||
onClick={handleContinuePending}
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
py: 1.1,
|
||||
textTransform: 'none',
|
||||
fontFamily: 'Benzin-Bold, sans-serif',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.14)',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.09)' },
|
||||
}}
|
||||
>
|
||||
У вас {pendingCount} неподтвержденный аккаунт. Подтвердить сейчас
|
||||
</Button> */}
|
||||
<Typography>
|
||||
У вас {pendingCount} неподтвержденный аккаунт. <span onClick={handleContinuePending} style={{ cursor: 'pointer', borderBottom: '1px solid #fff'}}>Подтвердить сейчас</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Segmented
|
||||
exclusive
|
||||
value={showPasswordLogin ? 'password' : 'qr'}
|
||||
onChange={(_, v) => {
|
||||
if (!v) return;
|
||||
if (v === 'password') goToPasswordLogin();
|
||||
else backToQr();
|
||||
}}
|
||||
sx={{ mb: '1vw' }}
|
||||
>
|
||||
<ToggleButton value="qr">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<TelegramIcon sx={{ fontSize: 18 }} />
|
||||
<span style={{textTransform: 'none'}}>Telegram QR</span>
|
||||
</Stack>
|
||||
</ToggleButton>
|
||||
|
||||
<ToggleButton value="password">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<KeyIcon sx={{ fontSize: 18 }} />
|
||||
<span style={{textTransform: 'none'}}>Логин + пароль</span>
|
||||
</Stack>
|
||||
</ToggleButton>
|
||||
</Segmented>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)', mb: '1.6vw' }} />
|
||||
|
||||
{/* content */}
|
||||
{!showPasswordLogin ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: '1.05fr 0.95fr' },
|
||||
gap: '1.6vw',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{/* QR card */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: '1.6vw',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03))',
|
||||
p: '1.2vw',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* subtle top glow like marketplace cards */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
background:
|
||||
'radial-gradient(circle at top, rgba(242,113,33,0.18), transparent 60%)',
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{/* IMPORTANT: relative wrapper so expired overlay is positioned correctly */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: '1.2vw',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(40,40,40,0.55), rgba(15,15,15,0.55))',
|
||||
// minHeight: 340,
|
||||
// py: 2,
|
||||
boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.04)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: -2,
|
||||
borderRadius: '1.3vw',
|
||||
padding: '2px',
|
||||
background:
|
||||
'linear-gradient(90deg, rgba(242,113,33,0.0), rgba(242,113,33,0.35), rgba(233,64,205,0.35), rgba(138,35,135,0.35), rgba(242,113,33,0.0))',
|
||||
backgroundSize: '240% 240%',
|
||||
animation: `${borderShimmer} 7s ease-in-out infinite`,
|
||||
pointerEvents: 'none',
|
||||
mask:
|
||||
'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
|
||||
WebkitMask:
|
||||
'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
|
||||
maskComposite: 'exclude',
|
||||
WebkitMaskComposite: 'xor',
|
||||
opacity: qrState === 'expired' ? 0.18 : 0.45,
|
||||
}}
|
||||
/>
|
||||
<div ref={qrRef} style={{ minHeight: 300 }} />
|
||||
|
||||
{qrState === 'expired' && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(0,0,0,0.60), rgba(0,0,0,0.35))',
|
||||
backdropFilter: 'blur(10px)',
|
||||
textAlign: 'center',
|
||||
borderRadius: '1.2vw',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: 'clamp(14px, 1.05vw, 18px)',
|
||||
color: alpha('#fff', 0.92),
|
||||
}}
|
||||
>
|
||||
QR-код истёк
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 0.6,
|
||||
fontSize: 'clamp(12px, 0.9vw, 14px)',
|
||||
color: alpha('#fff', 0.75),
|
||||
}}
|
||||
>
|
||||
Нажми “Обновить QR”, чтобы получить новый
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
mt: 1,
|
||||
textAlign: 'center',
|
||||
fontSize: 'clamp(12px, 0.9vw, 14px)',
|
||||
color: alpha('#fff', 0.75),
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{qrState === 'polling' && 'Ожидаем подтверждение…'}
|
||||
{qrState === 'ready' && 'Сканируй QR в Telegram'}
|
||||
{qrState === 'expired' && 'Нужно обновить QR'}
|
||||
{qrState === 'idle' && 'Подготавливаем вход…'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* actions */}
|
||||
<Stack spacing={1.2} sx={{ pt: { xs: 0, md: '0.4vw' } }}>
|
||||
<GradientTitle sx={{ fontSize: 'clamp(16px, 1.2vw, 18px)' }}>
|
||||
Вход через Telegram
|
||||
</GradientTitle>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.70)',
|
||||
fontWeight: 700,
|
||||
fontSize: 'clamp(12px, 0.9vw, 14px)',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
1) Открой бота <br />
|
||||
2) Сканируй QR <br />
|
||||
3) Подтверди вход
|
||||
</Typography>
|
||||
|
||||
<GradientButton
|
||||
variant="contained"
|
||||
onClick={() => qrUrl && window.open(qrUrl, '_blank')}
|
||||
disabled={!qrUrl}
|
||||
startIcon={<OpenInNewRoundedIcon />}
|
||||
sx={{ py: 1.2, fontSize: 'clamp(12px, 0.95vw, 14px)' }}
|
||||
>
|
||||
Открыть бота
|
||||
</GradientButton>
|
||||
|
||||
<Button
|
||||
disableRipple
|
||||
disableFocusRipple
|
||||
onClick={startQrLogin}
|
||||
startIcon={<RefreshRoundedIcon />}
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
textTransform: 'none',
|
||||
py: 1.0,
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.08)' },
|
||||
}}
|
||||
>
|
||||
Обновить QR
|
||||
</Button>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)', my: 0.6 }} />
|
||||
|
||||
<Button
|
||||
disableRipple
|
||||
disableFocusRipple
|
||||
onClick={() => navigate('/registration')}
|
||||
startIcon={<PersonAddAlt1RoundedIcon />}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
borderRadius: 999,
|
||||
py: 1.0,
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: '#fff',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.08)' },
|
||||
}}
|
||||
>
|
||||
Зарегистрироваться
|
||||
</Button>
|
||||
|
||||
{qrLoading && <FullScreenLoader fullScreen={false} message="Генерируем QR..." />}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
/* password */
|
||||
<Box sx={{ display: 'grid', placeItems: 'center' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 'min(520px, 100%)',
|
||||
borderRadius: '1.6vw',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03))',
|
||||
p: '1.6vw',
|
||||
}}
|
||||
>
|
||||
<GradientTitle sx={{ fontSize: 'clamp(16px, 1.2vw, 18px)', mb: 1 }}>
|
||||
Вход по логину и паролю
|
||||
</GradientTitle>
|
||||
|
||||
<Stack spacing={1.2}>
|
||||
<GradientTextField
|
||||
label="Никнейм"
|
||||
required
|
||||
name="username"
|
||||
value={config.username}
|
||||
onChange={handleInputChange}
|
||||
sx={{
|
||||
'& .MuiInputLabel-root.Mui-focused': {
|
||||
display: 'none',
|
||||
},
|
||||
'& .MuiInputLabel-root.MuiInputLabel-shrink': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<GradientTextField
|
||||
label="Пароль"
|
||||
required
|
||||
name="password"
|
||||
type="password"
|
||||
value={config.password}
|
||||
onChange={handleInputChange}
|
||||
sx={{
|
||||
'& .MuiInputLabel-root.Mui-focused': {
|
||||
display: 'none',
|
||||
},
|
||||
'& .MuiInputLabel-root.MuiInputLabel-shrink': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<GradientButton
|
||||
variant="contained"
|
||||
onClick={handleLogin}
|
||||
sx={{
|
||||
py: 1.2,
|
||||
fontSize: 'clamp(12px, 0.95vw, 14px)',
|
||||
mt: 0.6,
|
||||
}}
|
||||
>
|
||||
Войти
|
||||
</GradientButton>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
fullWidth
|
||||
disableRipple
|
||||
disableFocusRipple
|
||||
onClick={() => navigate('/registration')}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
borderRadius: 999,
|
||||
py: 1.0,
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: '#fff',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.08)' },
|
||||
}}
|
||||
>
|
||||
Регистрация
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
disableRipple
|
||||
disableFocusRipple
|
||||
onClick={backToQr}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
borderRadius: 999,
|
||||
py: 1.0,
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: '#fff',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.08)' },
|
||||
}}
|
||||
>
|
||||
Назад к QR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</GlassPaper>
|
||||
)}
|
||||
|
||||
<CustomNotification
|
||||
open={notifOpen}
|
||||
message={notifMsg}
|
||||
severity={notifSeverity}
|
||||
position={notifPos}
|
||||
onClose={() => setNotifOpen(false)}
|
||||
autoHideDuration={2500}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
806
src/renderer/pages/News.tsx
Normal file
806
src/renderer/pages/News.tsx
Normal file
@ -0,0 +1,806 @@
|
||||
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';
|
||||
import React from 'react';
|
||||
import CustomNotification, {
|
||||
NotificationPosition,
|
||||
} from '../components/Notifications/CustomNotification';
|
||||
import {
|
||||
getNotifPositionFromSettings,
|
||||
isNotificationsEnabled,
|
||||
} from '../utils/notifications';
|
||||
|
||||
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 [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 [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 showNotification = (
|
||||
message: React.ReactNode,
|
||||
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
|
||||
position: NotificationPosition = getNotifPositionFromSettings(),
|
||||
) => {
|
||||
if (!isNotificationsEnabled()) return;
|
||||
setNotifMsg(message);
|
||||
setNotifSeverity(severity);
|
||||
setNotifPos(position);
|
||||
setNotifOpen(true);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const PromoInline = ({ code }: { code: string }) => {
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
showNotification(`Промокод ${code} скопирован`, 'success');
|
||||
} catch {
|
||||
// fallback для старых браузеров
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = code;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
showNotification(`Промокод ${code} скопирован`, 'success');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
onClick={handleCopy}
|
||||
title="Нажмите, чтобы скопировать промокод"
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
backgroundImage:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
px: '0.2em',
|
||||
whiteSpace: 'nowrap',
|
||||
|
||||
cursor: 'pointer', // 👈 показывает интерактивность
|
||||
userSelect: 'none',
|
||||
|
||||
'&:hover': {
|
||||
filter: 'brightness(1.15)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.97)',
|
||||
},
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWithPromoCodes = (text: string) => {
|
||||
const parts = text.split(/(\/\/[A-Z0-9-_]+)/g);
|
||||
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('//')) {
|
||||
const code = part.slice(2);
|
||||
return <PromoInline key={i} code={code} />;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
};
|
||||
|
||||
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, children, ...props }) => (
|
||||
<Typography
|
||||
{...props}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
fontSize: '1.5vw',
|
||||
lineHeight: 1.6,
|
||||
mb: 1,
|
||||
whiteSpace: 'pre-line',
|
||||
textAlign: 'center', // вместо alignItems center
|
||||
'&:last-of-type': { mb: 0 },
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{React.Children.map(children, (child) =>
|
||||
typeof child === 'string'
|
||||
? renderWithPromoCodes(child)
|
||||
: child,
|
||||
)}
|
||||
</Typography>
|
||||
),
|
||||
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>
|
||||
);
|
||||
})}
|
||||
<CustomNotification
|
||||
open={notifOpen}
|
||||
message={notifMsg}
|
||||
severity={notifSeverity}
|
||||
position={notifPos}
|
||||
onClose={() => setNotifOpen(false)}
|
||||
autoHideDuration={2500}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@ -19,14 +19,20 @@ import {
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
} 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 [walkingSpeed, setWalkingSpeed] = useState<number>(0.5);
|
||||
const [skin, setSkin] = useState<string>('');
|
||||
const [cape, setCape] = useState<string>('');
|
||||
const [username, setUsername] = useState<string>('');
|
||||
@ -41,6 +47,24 @@ export default function Profile() {
|
||||
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) {
|
||||
@ -54,6 +78,25 @@ export default function Profile() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
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);
|
||||
@ -130,8 +173,17 @@ export default function Profile() {
|
||||
const handleUploadSkin = async () => {
|
||||
setLoading(true);
|
||||
if (!skinFile || !username) {
|
||||
setStatusMessage('Необходимо выбрать файл и указать имя пользователя');
|
||||
const msg = 'Необходимо выбрать файл и указать имя пользователя';
|
||||
setStatusMessage(msg);
|
||||
setUploadStatus('error');
|
||||
|
||||
setLoading(false);
|
||||
|
||||
// notification
|
||||
if (!isNotificationsEnabled()) return;
|
||||
setNotifMsg(msg);
|
||||
setNotifSeverity('error');
|
||||
setNotifOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -143,103 +195,223 @@ export default function Profile() {
|
||||
setStatusMessage('Скин успешно загружен!');
|
||||
setUploadStatus('success');
|
||||
|
||||
// Обновляем информацию о игроке, чтобы увидеть новый скин
|
||||
const config = JSON.parse(
|
||||
localStorage.getItem('launcher_config') || '{}',
|
||||
);
|
||||
// 1) подтягиваем свежий skin_url с бэка
|
||||
const config = JSON.parse(localStorage.getItem('launcher_config') || '{}');
|
||||
if (config.uuid) {
|
||||
loadPlayerData(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) {
|
||||
setStatusMessage(
|
||||
`Ошибка: ${error instanceof Error ? error.message : 'Не удалось загрузить скин'}`,
|
||||
);
|
||||
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={{
|
||||
my: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '100px',
|
||||
mt: '10vh',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
boxSizing: 'border-box',
|
||||
px: '2vw',
|
||||
}}
|
||||
>
|
||||
<CustomNotification
|
||||
open={notifOpen}
|
||||
message={notifMsg}
|
||||
severity={notifSeverity}
|
||||
position={notifPos}
|
||||
onClose={() => setNotifOpen(false)}
|
||||
autoHideDuration={2500}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<CircularProgress />
|
||||
<FullScreenLoader message="Загрузка вашего профиля" />
|
||||
) : (
|
||||
<>
|
||||
<Paper
|
||||
elevation={0}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
|
||||
gap: '3vw',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{/* LEFT COLUMN */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 0,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
mb: 4,
|
||||
bgcolor: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1vw',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{/* Используем переработанный компонент SkinViewer */}
|
||||
{/* Плашка с ником */}
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
alignSelf: 'center',
|
||||
justifySelf: 'center',
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
mb: '5vw',
|
||||
fontSize: '3vw',
|
||||
color: 'white',
|
||||
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
|
||||
width={300}
|
||||
height={400}
|
||||
skinUrl={skin}
|
||||
capeUrl={cape}
|
||||
walkingSpeed={walkingSpeed}
|
||||
autoRotate={true}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{/* SkinViewer */}
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<SkinViewer
|
||||
width={340}
|
||||
height={405}
|
||||
skinUrl={skin}
|
||||
capeUrl={cape}
|
||||
walkingSpeed={walkingSpeed}
|
||||
autoRotate={autoRotate}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Загрузчик скинов */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: '500px',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
padding: '3vw',
|
||||
borderRadius: '1vw',
|
||||
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={{
|
||||
border: '2px dashed',
|
||||
borderColor: isDragOver ? 'primary.main' : 'grey.400',
|
||||
borderRadius: 2,
|
||||
p: 3,
|
||||
mb: 2,
|
||||
borderRadius: '1.1vw',
|
||||
p: '1.6vw',
|
||||
mb: '1.1vw',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
bgcolor: isDragOver
|
||||
? 'rgba(25, 118, 210, 0.08)'
|
||||
: 'transparent',
|
||||
transition: 'all 0.2s',
|
||||
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();
|
||||
@ -256,34 +428,91 @@ export default function Profile() {
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Typography sx={{ color: 'white' }}>
|
||||
|
||||
<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
|
||||
color="primary"
|
||||
fullWidth
|
||||
sx={{ mb: 2, color: 'white' }}
|
||||
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 sx={{ color: 'white' }}>Модель скина</InputLabel>
|
||||
<InputLabel>Модель скина</InputLabel>
|
||||
<Select
|
||||
value={skinModel}
|
||||
label="Модель скина"
|
||||
onChange={(e) => setSkinModel(e.target.value)}
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderColor: 'white',
|
||||
'& .MuiInputBase-input': {
|
||||
border: '1px solid white',
|
||||
transition: 'unset',
|
||||
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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
'&:focus': {
|
||||
borderRadius: 4,
|
||||
borderColor: '#80bdff',
|
||||
boxShadow: '0 0 0 0.2rem rgba(0,123,255,.25)',
|
||||
}}
|
||||
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)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
@ -293,63 +522,80 @@ export default function Profile() {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{uploadStatus === 'error' && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{statusMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadStatus === 'success' && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{statusMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* button */}
|
||||
<Button
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderRadius: '20px',
|
||||
p: '10px 25px',
|
||||
backgroundColor: 'rgb(0, 134, 0)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 134, 0, 0.5)',
|
||||
},
|
||||
fontFamily: 'Benzin-Bold',
|
||||
}}
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={handleUploadSkin}
|
||||
disabled={uploadStatus === 'loading' || !skinFile}
|
||||
startIcon={
|
||||
uploadStatus === 'loading' ? (
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
) : null
|
||||
}
|
||||
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' ? (
|
||||
<Typography sx={{ color: 'white' }}>Загрузка...</Typography>
|
||||
) : (
|
||||
<Typography sx={{ color: 'white' }}>
|
||||
Установить скин
|
||||
</Typography>
|
||||
)}
|
||||
{uploadStatus === 'loading' ? 'Загрузка...' : 'Установить скин'}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box
|
||||
</Box>
|
||||
|
||||
{/* RIGHT COLUMN */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1vw',
|
||||
minWidth: 0,
|
||||
maxWidth: '44vw',
|
||||
justifySelf: 'start',
|
||||
}}
|
||||
>
|
||||
{/* Плащи */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
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>Ваши плащи</Typography>
|
||||
<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',
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
flexWrap: 'wrap',
|
||||
gap: '1.2vw',
|
||||
}}
|
||||
>
|
||||
{capes.map((cape) => (
|
||||
@ -357,16 +603,17 @@ export default function Profile() {
|
||||
key={cape.cape_id}
|
||||
cape={cape}
|
||||
mode="profile"
|
||||
onAction={
|
||||
cape.is_active ? handleDeactivateCape : handleActivateCape
|
||||
}
|
||||
onAction={cape.is_active ? handleDeactivateCape : handleActivateCape}
|
||||
actionDisabled={loading}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Онлайн */}
|
||||
<OnlinePlayersPanel currentUsername={username} />
|
||||
</Box>
|
||||
</>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
170
src/renderer/pages/PromoRedeem.tsx
Normal file
170
src/renderer/pages/PromoRedeem.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { Box, Button, Typography } from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import GradientTextField from '../components/GradientTextField';
|
||||
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||
import {
|
||||
isNotificationsEnabled,
|
||||
getNotifPositionFromSettings,
|
||||
} from '../utils/notifications';
|
||||
import { redeemPromoCode } from '../api/promocodes';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const PromoRedeem = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [username, setUsername] = useState<string>(''); // будет автозаполнение
|
||||
const [code, setCode] = useState('');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
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 showNotification = (
|
||||
message: React.ReactNode,
|
||||
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
|
||||
position: NotificationPosition = getNotifPositionFromSettings(),
|
||||
) => {
|
||||
if (!isNotificationsEnabled()) return;
|
||||
setNotifMsg(message);
|
||||
setNotifSeverity(severity);
|
||||
setNotifPos(position);
|
||||
setNotifOpen(true);
|
||||
};
|
||||
|
||||
// как в Profile.tsx: читаем launcher_config
|
||||
useEffect(() => {
|
||||
try {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
if (savedConfig) {
|
||||
const config = JSON.parse(savedConfig);
|
||||
setUsername(config.username || '');
|
||||
}
|
||||
} catch {
|
||||
setUsername('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRedeem = async () => {
|
||||
if (!username) {
|
||||
showNotification(
|
||||
'Не удалось определить никнейм. Войдите в аккаунт заново.',
|
||||
'warning',
|
||||
);
|
||||
// по желанию можно отправить в login/profile:
|
||||
// navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
showNotification('Введите промокод', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await redeemPromoCode(username, code);
|
||||
|
||||
showNotification(
|
||||
<>
|
||||
Промокод <b>{res.code}</b> успешно активирован!
|
||||
</>,
|
||||
'success',
|
||||
);
|
||||
|
||||
setCode('');
|
||||
} catch (e: any) {
|
||||
showNotification(e?.message || 'Ошибка активации промокода', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
height: 'calc(100vh - 8vh)',
|
||||
pt: '8vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
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',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Активация промокода
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '50vw',
|
||||
}}
|
||||
>
|
||||
<GradientTextField
|
||||
label=""
|
||||
required
|
||||
name="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
sx={{
|
||||
transition: 'transform 0.3s ease',
|
||||
width: '100%',
|
||||
mt: 2,
|
||||
background:
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
borderRadius: '2.5vw',
|
||||
fontSize: '2vw',
|
||||
'&:hover': {
|
||||
transform: loading ? 'none' : 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
onClick={handleRedeem}
|
||||
>
|
||||
{loading ? 'Активируем...' : 'Активировать'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<CustomNotification
|
||||
open={notifOpen}
|
||||
message={notifMsg}
|
||||
severity={notifSeverity}
|
||||
position={notifPos}
|
||||
onClose={() => setNotifOpen(false)}
|
||||
autoHideDuration={2500}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
598
src/renderer/pages/Settings.tsx
Normal file
598
src/renderer/pages/Settings.tsx
Normal file
@ -0,0 +1,598 @@
|
||||
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';
|
||||
import SettingCheckboxRow from '../components/CustomComponents/SettingCheckboxRow';
|
||||
|
||||
type SettingsState = {
|
||||
// UI
|
||||
uiScale: number; // 80..120
|
||||
reduceMotion: boolean;
|
||||
blurEffects: boolean;
|
||||
|
||||
// Launcher / app
|
||||
autoLaunch: boolean;
|
||||
startInTray: boolean;
|
||||
closeToTray: boolean;
|
||||
disableToolTip: boolean;
|
||||
allowEssentialTooltips: boolean;
|
||||
|
||||
// Game
|
||||
autoRotateSkinViewer: boolean;
|
||||
walkingSpeed: number; // 0..1
|
||||
|
||||
// Notifications
|
||||
notifications: boolean;
|
||||
notificationPosition: 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left';
|
||||
|
||||
// Navigation
|
||||
rememberLastRoute: boolean;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'launcher_settings';
|
||||
|
||||
const GRADIENT =
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||
|
||||
const SLIDER_SX = {
|
||||
mt: 0.6,
|
||||
|
||||
'& .MuiSlider-rail': {
|
||||
opacity: 1,
|
||||
height: '0.55vw',
|
||||
borderRadius: '999px',
|
||||
backgroundColor: 'rgba(255,255,255,0.10)',
|
||||
},
|
||||
|
||||
'& .MuiSlider-track': {
|
||||
height: '0.55vw',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
background:
|
||||
'linear-gradient(90deg, rgba(242,113,33,1) 0%, rgba(233,64,205,1) 55%, rgba(138,35,135,1) 100%)',
|
||||
boxShadow: '0 0.6vw 1.6vw rgba(233,64,205,0.18)',
|
||||
},
|
||||
|
||||
'& .MuiSlider-thumb': {
|
||||
width: '1.65vw',
|
||||
height: '1.65vw',
|
||||
borderRadius: '999px',
|
||||
backgroundColor: 'rgba(10,10,20,0.92)',
|
||||
border: '2px solid rgba(255,255,255,0.18)',
|
||||
boxShadow: '0 0 1.6vw rgba(233,64,205,0.35)',
|
||||
transition: 'transform 0.15s ease, box-shadow 0.15s ease, height 0.3s ease, width 0.3s ease',
|
||||
'&:before': { display: 'none' },
|
||||
'&:hover, &.Mui-focusVisible': {
|
||||
width: '1.95vw',
|
||||
height: '1.95vw',
|
||||
boxShadow: '0 0 2.2vw rgba(242,113,33,0.35)',
|
||||
},
|
||||
'&:active': { width: '1.95vw', height: '1.95vw', },
|
||||
},
|
||||
|
||||
'& .MuiSlider-valueLabel': {
|
||||
borderRadius: '999px',
|
||||
background: 'rgba(10,10,20,0.92)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const defaultSettings: SettingsState = {
|
||||
uiScale: 100,
|
||||
reduceMotion: false,
|
||||
blurEffects: true,
|
||||
|
||||
startInTray: false,
|
||||
autoLaunch: false,
|
||||
closeToTray: true,
|
||||
disableToolTip: false,
|
||||
allowEssentialTooltips: true,
|
||||
|
||||
autoRotateSkinViewer: true,
|
||||
walkingSpeed: 0.5,
|
||||
|
||||
notifications: true,
|
||||
notificationPosition: 'bottom-center',
|
||||
|
||||
rememberLastRoute: true,
|
||||
};
|
||||
|
||||
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 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 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 setFlag =
|
||||
<K extends keyof SettingsState>(key: K) =>
|
||||
(v: SettingsState[K]) =>
|
||||
setSettings((s) => ({ ...s, [key]: v }));
|
||||
|
||||
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 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: '95%',
|
||||
boxSizing: 'border-box',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<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={SLIDER_SX}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
|
||||
|
||||
<SettingCheckboxRow
|
||||
title="Уменьшить анимации"
|
||||
description="Отключить все анимации лаунчера"
|
||||
checked={settings.reduceMotion}
|
||||
onChange={setFlag('reduceMotion')}
|
||||
/>
|
||||
|
||||
<SettingCheckboxRow
|
||||
title="Эффекты размытия (blur)"
|
||||
description="Компоненты будут прозрачными без размытия"
|
||||
checked={settings.blurEffects}
|
||||
onChange={setFlag('blurEffects')}
|
||||
/>
|
||||
</Box>
|
||||
</Glass>
|
||||
|
||||
<Glass>
|
||||
<SectionTitle>Уведомления</SectionTitle>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
|
||||
<SettingCheckboxRow
|
||||
title="Включить уведомления"
|
||||
description="Уведомления о каких-либо действиях"
|
||||
checked={settings.notifications}
|
||||
onChange={setFlag('notifications')}
|
||||
/>
|
||||
|
||||
<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' }}>
|
||||
<SettingCheckboxRow
|
||||
title="Автоповорот персонажа в профиле"
|
||||
description="Прокрут игрового персонажа в профиле"
|
||||
checked={settings.autoRotateSkinViewer}
|
||||
onChange={setFlag('autoRotateSkinViewer')}
|
||||
/>
|
||||
|
||||
<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={SLIDER_SX}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Glass>
|
||||
|
||||
<Glass>
|
||||
<SectionTitle>Лаунчер</SectionTitle>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
|
||||
<SettingCheckboxRow
|
||||
title="Запускать вместе с системой"
|
||||
description="Лаунчер будет запускаться при старте Windows"
|
||||
checked={settings.autoLaunch}
|
||||
onChange={setFlag('autoLaunch')}
|
||||
/>
|
||||
|
||||
<SettingCheckboxRow
|
||||
title="Запускать свернутым (в трей)"
|
||||
description="Окно не показывается при старте"
|
||||
checked={settings.startInTray}
|
||||
onChange={setFlag('startInTray')}
|
||||
/>
|
||||
|
||||
<SettingCheckboxRow
|
||||
title="При закрытии сворачивать в трей"
|
||||
description="Крестик не закрывает приложение полностью"
|
||||
checked={settings.closeToTray}
|
||||
onChange={setFlag('closeToTray')}
|
||||
/>
|
||||
|
||||
<SettingCheckboxRow
|
||||
title="Запоминать последнюю страницу"
|
||||
description="После перезапуска откроется тот же раздел"
|
||||
checked={settings.rememberLastRoute}
|
||||
onChange={setFlag('rememberLastRoute')}
|
||||
/>
|
||||
|
||||
<SettingCheckboxRow
|
||||
title="Отключить подсказки"
|
||||
description="Отключить подсказки при наведении на элементы"
|
||||
checked={settings.disableToolTip}
|
||||
onChange={setFlag('disableToolTip')}
|
||||
/>
|
||||
|
||||
<SettingCheckboxRow
|
||||
title="Показывать важные подсказки"
|
||||
description="Некоторые подсказки нельзя отключить (важные)"
|
||||
checked={settings.allowEssentialTooltips}
|
||||
onChange={setFlag('allowEssentialTooltips')}
|
||||
/>
|
||||
</Box>
|
||||
</Glass>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,20 +4,16 @@ import {
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Modal,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||||
|
||||
interface VersionCardProps {
|
||||
id: string;
|
||||
@ -25,33 +21,53 @@ interface VersionCardProps {
|
||||
imageUrl: string;
|
||||
version: string;
|
||||
onSelect: (id: string) => void;
|
||||
isHovered: boolean;
|
||||
onHover: (id: string | null) => void;
|
||||
hoveredCardId: string | null;
|
||||
}
|
||||
|
||||
const VersionCard: React.FC<VersionCardProps> = ({
|
||||
const gradientPrimary =
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||
|
||||
export const VersionCard: React.FC<VersionCardProps> = ({
|
||||
id,
|
||||
name,
|
||||
imageUrl,
|
||||
imageUrl, // пока не используется, но оставляем для будущего
|
||||
version,
|
||||
onSelect,
|
||||
isHovered,
|
||||
onHover,
|
||||
hoveredCardId,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: 'rgba(30, 30, 50, 0.8)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
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: '16px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'transform 0.3s, box-shadow 0.3s',
|
||||
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={{
|
||||
@ -60,7 +76,8 @@ const VersionCard: React.FC<VersionCardProps> = ({
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '10%',
|
||||
gap: '1vh',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
@ -69,12 +86,24 @@ const VersionCard: React.FC<VersionCardProps> = ({
|
||||
component="div"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
fontSize: '1.5rem',
|
||||
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>
|
||||
);
|
||||
@ -125,6 +154,7 @@ export const VersionsExplorer = () => {
|
||||
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(() => {
|
||||
@ -153,7 +183,6 @@ export const VersionsExplorer = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке версий:', error);
|
||||
// Можно добавить обработку ошибки, например показать уведомление
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -162,11 +191,15 @@ export const VersionsExplorer = () => {
|
||||
fetchVersions();
|
||||
}, []);
|
||||
|
||||
const handleSelectVersion = (version: VersionInfo) => {
|
||||
localStorage.setItem(
|
||||
'selected_version_config',
|
||||
JSON.stringify(version.config || {}),
|
||||
);
|
||||
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}`);
|
||||
};
|
||||
|
||||
@ -182,7 +215,6 @@ export const VersionsExplorer = () => {
|
||||
try {
|
||||
setDownloadLoading(version.id);
|
||||
|
||||
// Скачивание и установка выбранной версии
|
||||
const downloadResult = await window.electron.ipcRenderer.invoke(
|
||||
'download-and-extract',
|
||||
{
|
||||
@ -195,7 +227,6 @@ export const VersionsExplorer = () => {
|
||||
);
|
||||
|
||||
if (downloadResult?.success) {
|
||||
// Добавляем скачанную версию в список установленных
|
||||
setInstalledVersions((prev) => [...prev, version]);
|
||||
setModalOpen(false);
|
||||
}
|
||||
@ -210,40 +241,41 @@ export const VersionsExplorer = () => {
|
||||
const AddVersionCard = () => (
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: 'rgba(30, 30, 50, 0.8)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
background:
|
||||
'radial-gradient(circle at top left, rgba(233,64,205,0.3), rgba(10,10,20,0.95))',
|
||||
width: '35vw',
|
||||
height: '35vh',
|
||||
minWidth: 'unset',
|
||||
minHeight: 'unset',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'transform 0.3s, box-shadow 0.3s',
|
||||
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: 60, color: '#fff' }} />
|
||||
<AddIcon sx={{ fontSize: '4vw', color: '#fff' }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#fff',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1.5vw',
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
Добавить
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
версию
|
||||
Добавить версию
|
||||
</Typography>
|
||||
</Card>
|
||||
);
|
||||
@ -251,33 +283,45 @@ export const VersionsExplorer = () => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
px: '5vw',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '5vw',
|
||||
paddingRight: '5vw',
|
||||
gap: '2vh',
|
||||
height: '100%',
|
||||
width: '85%',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" my={5}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
<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}
|
||||
size={{ xs: 'auto', sm: 'auto', md: 'auto' }}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2vh',
|
||||
}}
|
||||
>
|
||||
<VersionCard
|
||||
id={version.id}
|
||||
@ -288,19 +332,40 @@ export const VersionsExplorer = () => {
|
||||
}
|
||||
version={version.version}
|
||||
onSelect={() => handleSelectVersion(version)}
|
||||
isHovered={hoveredCardId === version.id}
|
||||
onHover={setHoveredCardId}
|
||||
hoveredCardId={hoveredCardId}
|
||||
/>
|
||||
</Grid>
|
||||
))
|
||||
) : (
|
||||
// Если нет ни одной версии, показываем карточку добавления
|
||||
<Grid size={{ xs: 'auto', sm: 'auto', md: 'auto' }}>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2vh',
|
||||
}}
|
||||
>
|
||||
<AddVersionCard />
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Всегда добавляем карточку для добавления новых версий */}
|
||||
{installedVersions.length > 0 && (
|
||||
<Grid size={{ xs: 'auto', sm: 'auto', md: 'auto' }}>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2vh',
|
||||
}}
|
||||
>
|
||||
<AddVersionCard />
|
||||
</Grid>
|
||||
)}
|
||||
@ -320,21 +385,29 @@ export const VersionsExplorer = () => {
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
width: 420,
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
background: 'linear-gradient(45deg, #000000 10%, #3b4187 184.73%)',
|
||||
border: '2px solid #000',
|
||||
boxShadow: 24,
|
||||
background: 'linear-gradient(145deg, #000000 10%, #8A2387 100%)',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.85)',
|
||||
p: 4,
|
||||
borderRadius: '3vw',
|
||||
gap: '1vh',
|
||||
borderRadius: '2.5vw',
|
||||
gap: '1.5vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backdropFilter: 'blur(10px)',
|
||||
backdropFilter: 'blur(18px)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="h2" sx={{ color: '#fff' }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="h2"
|
||||
sx={{
|
||||
color: '#fff',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Доступные версии для скачивания
|
||||
</Typography>
|
||||
|
||||
@ -343,17 +416,22 @@ export const VersionsExplorer = () => {
|
||||
Загрузка доступных версий...
|
||||
</Typography>
|
||||
) : (
|
||||
<List sx={{ mt: 2 }}>
|
||||
<List sx={{ mt: 1 }}>
|
||||
{availableVersions.map((version) => (
|
||||
<ListItem
|
||||
key={version.id}
|
||||
sx={{
|
||||
borderRadius: '8px',
|
||||
borderRadius: '1vw',
|
||||
mb: 1,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.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.2)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
|
||||
},
|
||||
}}
|
||||
onClick={() => handleSelectVersion(version)}
|
||||
@ -361,11 +439,22 @@ export const VersionsExplorer = () => {
|
||||
<ListItemText
|
||||
primary={version.name}
|
||||
secondary={version.version}
|
||||
primaryTypographyProps={{ color: '#fff' }}
|
||||
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>
|
||||
@ -373,15 +462,20 @@ export const VersionsExplorer = () => {
|
||||
|
||||
<Button
|
||||
onClick={handleCloseModal}
|
||||
variant="outlined"
|
||||
variant="contained"
|
||||
sx={{
|
||||
mt: 3,
|
||||
alignSelf: 'center',
|
||||
borderColor: '#fff',
|
||||
color: '#fff',
|
||||
px: 6,
|
||||
py: 1.2,
|
||||
borderRadius: '2.5vw',
|
||||
background: gradientPrimary,
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1vw',
|
||||
textTransform: 'none',
|
||||
'&:hover': {
|
||||
borderColor: '#ccc',
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
transform: 'scale(1.01)',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.6)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
163
src/renderer/pages/VoicePage.tsx
Normal file
163
src/renderer/pages/VoicePage.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { RoomsPanel } from '../components/Voice/RoomsPanel';
|
||||
import { VoicePanel } from '../components/Voice/VoicePanel';
|
||||
import {
|
||||
fetchRooms,
|
||||
createPublicRoom,
|
||||
joinPrivateRoom,
|
||||
} from '../api/voiceRooms';
|
||||
import { mapApiRoomToUI } from '../mappers/roomMapper';
|
||||
import type { RoomInfo } from '../types/rooms';
|
||||
import { CreateRoomDialog } from '../components/Voice/CreateRoomDialog';
|
||||
import { JoinByCodeDialog } from '../components/Voice/JoinByCodeDialog';
|
||||
import { useVoiceRoom } from '../realtime/voice/useVoiceRoom';
|
||||
|
||||
export default function VoicePage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [rooms, setRooms] = useState<RoomInfo[]>([]);
|
||||
const [currentRoomId, setCurrentRoomId] = useState<string | null>(null);
|
||||
const [currentRoom, setCurrentRoom] = useState<RoomInfo | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [joinOpen, setJoinOpen] = useState(false);
|
||||
|
||||
const voice = useVoiceRoom(username);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// --- username ---
|
||||
useEffect(() => {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
if (savedConfig) {
|
||||
const config = JSON.parse(savedConfig);
|
||||
if (config.username) {
|
||||
setUsername(config.username);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --- HTTP: initial rooms list ---
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
|
||||
fetchRooms()
|
||||
.then((apiRooms) => {
|
||||
setRooms(apiRooms.map(mapApiRoomToUI));
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [username]);
|
||||
|
||||
// --- open dialog ---
|
||||
const handleCreateClick = () => {
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
// --- WS: users inside rooms ---
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
|
||||
const ws = new WebSocket(
|
||||
`wss://minecraft.api.popa-popa.ru/ws/rooms?username=${username}`,
|
||||
);
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
|
||||
if (msg.type !== 'rooms') return;
|
||||
|
||||
setRooms((prev) => {
|
||||
const map = new Map(prev.map((r) => [r.id, r]));
|
||||
|
||||
for (const updated of msg.rooms) {
|
||||
const existing = map.get(updated.id);
|
||||
if (!existing) continue;
|
||||
|
||||
map.set(updated.id, {
|
||||
...existing,
|
||||
users: updated.users ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
});
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
}, [username]);
|
||||
|
||||
// --- handlers ---
|
||||
const joinRoom = (roomId: string) => {
|
||||
const room = rooms.find((r) => r.id === roomId);
|
||||
if (!room) return;
|
||||
setCurrentRoomId(roomId);
|
||||
setCurrentRoom(room);
|
||||
voice.connect(roomId); // 🔥 АВТОПОДКЛЮЧЕНИЕ
|
||||
};
|
||||
|
||||
const handleCreateRoom = async ({
|
||||
name,
|
||||
isPublic,
|
||||
}: {
|
||||
name: string;
|
||||
isPublic: boolean;
|
||||
}) => {
|
||||
const apiRoom = await createPublicRoom(name, username, isPublic);
|
||||
|
||||
const room: RoomInfo = mapApiRoomToUI(apiRoom);
|
||||
|
||||
setRooms((prev) => [room, ...prev]);
|
||||
|
||||
setCurrentRoomId(apiRoom.id);
|
||||
|
||||
return {
|
||||
invite_code: apiRoom.invite_code ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const handleJoinByCode = async (code: string) => {
|
||||
const room = await joinPrivateRoom(code);
|
||||
setCurrentRoomId(room.id);
|
||||
setCurrentRoom({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
public: false,
|
||||
users: [],
|
||||
maxUsers: room.max_users,
|
||||
});
|
||||
};
|
||||
|
||||
if (!username) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ display: 'flex', width: '98%', height: '90%', marginTop: '9vh' }}
|
||||
>
|
||||
<RoomsPanel
|
||||
rooms={rooms}
|
||||
currentRoomId={currentRoomId}
|
||||
onJoin={joinRoom}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onJoinByCode={() => setJoinOpen(true)}
|
||||
/>
|
||||
|
||||
<CreateRoomDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={handleCreateRoom}
|
||||
/>
|
||||
|
||||
<JoinByCodeDialog
|
||||
open={joinOpen}
|
||||
onClose={() => setJoinOpen(false)}
|
||||
onJoin={handleJoinByCode}
|
||||
/>
|
||||
|
||||
{currentRoomId && (
|
||||
<VoicePanel
|
||||
roomId={currentRoomId}
|
||||
voice={voice}
|
||||
roomName={currentRoom.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/renderer/realtime/voice/rtcConfig.ts
Normal file
3
src/renderer/realtime/voice/rtcConfig.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const rtcConfig: RTCConfiguration = {
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
};
|
||||
13
src/renderer/realtime/voice/types.ts
Normal file
13
src/renderer/realtime/voice/types.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type WSMessage =
|
||||
| { type: 'users'; users: string[] }
|
||||
| { type: 'join'; user: string }
|
||||
| { type: 'leave'; user: string }
|
||||
| {
|
||||
type: 'signal';
|
||||
from: string;
|
||||
data: {
|
||||
type: 'offer' | 'answer' | 'ice';
|
||||
sdp?: RTCSessionDescriptionInit;
|
||||
candidate?: RTCIceCandidateInit;
|
||||
};
|
||||
};
|
||||
298
src/renderer/realtime/voice/useVoiceRoom.ts
Normal file
298
src/renderer/realtime/voice/useVoiceRoom.ts
Normal file
@ -0,0 +1,298 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { rtcConfig } from './rtcConfig';
|
||||
import type { WSMessage } from './types';
|
||||
import { setVoiceState, getVoiceState } from './voiceStore';
|
||||
|
||||
type PeerMap = Map<string, RTCPeerConnection>;
|
||||
|
||||
export function useVoiceRoom(username: string) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const peersRef = useRef<PeerMap>(new Map());
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
const currentRoomIdRef = useRef<string | null>(null);
|
||||
|
||||
const reconnectTimeout = useRef<number | null>(null);
|
||||
|
||||
// const [connected, setConnected] = useState(false);
|
||||
// const [participants, setParticipants] = useState<string[]>([]);
|
||||
// const [muted, setMuted] = useState(false);
|
||||
|
||||
const pendingIceRef = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
|
||||
|
||||
// --- connect ---
|
||||
const connect = useCallback(
|
||||
async (roomId: string) => {
|
||||
if (wsRef.current) return;
|
||||
|
||||
currentRoomIdRef.current = roomId;
|
||||
|
||||
// 1. микрофон
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
streamRef.current = stream;
|
||||
|
||||
// 2. websocket
|
||||
const ws = new WebSocket(
|
||||
`wss://minecraft.api.popa-popa.ru/ws/voice?room_id=${roomId}&username=${username}`,
|
||||
);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setVoiceState({
|
||||
connected: true,
|
||||
shouldBeConnected: true,
|
||||
participants: [username],
|
||||
});
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
cleanup();
|
||||
|
||||
setVoiceState({ connected: false });
|
||||
|
||||
if (getVoiceState().shouldBeConnected) {
|
||||
reconnectTimeout.current = window.setTimeout(() => {
|
||||
const lastRoomId = currentRoomIdRef.current;
|
||||
if (lastRoomId) {
|
||||
connect(lastRoomId);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
// ws.onclose = () => {
|
||||
// cleanup();
|
||||
// setConnected(false);
|
||||
// };
|
||||
|
||||
ws.onmessage = async (ev) => {
|
||||
const msg: WSMessage = JSON.parse(ev.data);
|
||||
|
||||
if (msg.type === 'join' && msg.user !== username) {
|
||||
await createPeer(msg.user, false);
|
||||
|
||||
const { participants } = getVoiceState();
|
||||
if (!participants.includes(msg.user)) {
|
||||
setVoiceState({
|
||||
participants: [...participants, msg.user],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'leave') {
|
||||
removePeer(msg.user);
|
||||
|
||||
setVoiceState({
|
||||
participants: getVoiceState().participants.filter(
|
||||
(u) => u !== msg.user,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (msg.type === 'signal') {
|
||||
await handleSignal(msg.from, msg.data);
|
||||
}
|
||||
|
||||
if (msg.type === 'users') {
|
||||
const current = getVoiceState().participants;
|
||||
const next = msg.users;
|
||||
|
||||
// 1. удаляем ушедших
|
||||
for (const user of current) {
|
||||
if (!next.includes(user)) {
|
||||
removePeer(user);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. создаём peer для новых
|
||||
for (const user of next) {
|
||||
if (user !== username && !peersRef.current.has(user)) {
|
||||
await createPeer(user, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. обновляем store
|
||||
setVoiceState({
|
||||
participants: next,
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
[username],
|
||||
);
|
||||
|
||||
// --- create peer ---
|
||||
const createPeer = async (user: string, polite: boolean) => {
|
||||
if (peersRef.current.has(user)) return;
|
||||
|
||||
const pc = new RTCPeerConnection(rtcConfig);
|
||||
peersRef.current.set(user, pc);
|
||||
|
||||
streamRef.current
|
||||
?.getTracks()
|
||||
.forEach((t) => pc.addTrack(t, streamRef.current!));
|
||||
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate) {
|
||||
wsRef.current?.send(
|
||||
JSON.stringify({
|
||||
type: 'signal',
|
||||
to: user,
|
||||
data: { type: 'ice', candidate: e.candidate },
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
pc.ontrack = (e) => {
|
||||
const audio = document.createElement('audio');
|
||||
audio.srcObject = e.streams[0];
|
||||
audio.autoplay = true;
|
||||
audio.setAttribute('data-user', user);
|
||||
document.body.appendChild(audio);
|
||||
};
|
||||
|
||||
if (!polite) {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
wsRef.current?.send(
|
||||
JSON.stringify({
|
||||
type: 'signal',
|
||||
to: user,
|
||||
data: { type: 'offer', sdp: offer },
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const removePeer = (user: string) => {
|
||||
const pc = peersRef.current.get(user);
|
||||
if (!pc) return;
|
||||
|
||||
pc.close();
|
||||
peersRef.current.delete(user);
|
||||
|
||||
pendingIceRef.current.delete(user);
|
||||
|
||||
// удаляем audio элемент
|
||||
const audio = document.querySelector(
|
||||
`audio[data-user="${user}"]`,
|
||||
) as HTMLAudioElement | null;
|
||||
|
||||
audio?.remove();
|
||||
};
|
||||
|
||||
// --- signaling ---
|
||||
const handleSignal = async (from: string, data: any) => {
|
||||
let pc = peersRef.current.get(from);
|
||||
if (!pc) {
|
||||
await createPeer(from, true);
|
||||
pc = peersRef.current.get(from)!;
|
||||
}
|
||||
|
||||
if (data.type === 'offer') {
|
||||
if (pc.signalingState !== 'stable') {
|
||||
console.warn('Skip offer, state:', pc.signalingState);
|
||||
return;
|
||||
}
|
||||
|
||||
await pc.setRemoteDescription(data.sdp);
|
||||
|
||||
// 🔥 применяем накопленные ICE
|
||||
const queued = pendingIceRef.current.get(from);
|
||||
if (queued) {
|
||||
for (const c of queued) {
|
||||
await pc.addIceCandidate(c);
|
||||
}
|
||||
pendingIceRef.current.delete(from);
|
||||
}
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
|
||||
wsRef.current?.send(
|
||||
JSON.stringify({
|
||||
type: 'signal',
|
||||
to: from,
|
||||
data: { type: 'answer', sdp: answer },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.type === 'answer') {
|
||||
if (pc.signalingState === 'have-local-offer') {
|
||||
await pc.setRemoteDescription(data.sdp);
|
||||
|
||||
const queued = pendingIceRef.current.get(from);
|
||||
if (queued) {
|
||||
for (const c of queued) {
|
||||
await pc.addIceCandidate(c);
|
||||
}
|
||||
pendingIceRef.current.delete(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === 'ice') {
|
||||
if (pc.remoteDescription) {
|
||||
await pc.addIceCandidate(data.candidate);
|
||||
} else {
|
||||
// ⏳ remoteDescription ещё нет — сохраняем
|
||||
const queue = pendingIceRef.current.get(from) ?? [];
|
||||
queue.push(data.candidate);
|
||||
pendingIceRef.current.set(from, queue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- mute ---
|
||||
const toggleMute = () => {
|
||||
if (!streamRef.current) return;
|
||||
|
||||
const enabled = !getVoiceState().muted;
|
||||
|
||||
streamRef.current.getAudioTracks().forEach((t) => (t.enabled = !enabled));
|
||||
|
||||
setVoiceState({ muted: enabled });
|
||||
};
|
||||
|
||||
// --- cleanup ---
|
||||
const cleanup = () => {
|
||||
peersRef.current.forEach((pc) => pc.close());
|
||||
peersRef.current.clear();
|
||||
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
|
||||
if (reconnectTimeout.current) {
|
||||
clearTimeout(reconnectTimeout.current);
|
||||
reconnectTimeout.current = null;
|
||||
}
|
||||
|
||||
document.querySelectorAll('audio[data-user]').forEach((a) => a.remove());
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
setVoiceState({
|
||||
connected: false,
|
||||
shouldBeConnected: false,
|
||||
participants: [],
|
||||
muted: false,
|
||||
});
|
||||
cleanup();
|
||||
};
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMute,
|
||||
};
|
||||
}
|
||||
32
src/renderer/realtime/voice/voiceStore.ts
Normal file
32
src/renderer/realtime/voice/voiceStore.ts
Normal file
@ -0,0 +1,32 @@
|
||||
type VoiceState = {
|
||||
connected: boolean;
|
||||
shouldBeConnected: boolean;
|
||||
participants: string[];
|
||||
muted: boolean;
|
||||
};
|
||||
|
||||
const state: VoiceState = {
|
||||
connected: false,
|
||||
shouldBeConnected: false,
|
||||
participants: [],
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getVoiceState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function setVoiceState(patch: Partial<VoiceState>) {
|
||||
Object.assign(state, patch);
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function subscribeVoice(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
};
|
||||
}
|
||||
14
src/renderer/realtime/wsBase.ts
Normal file
14
src/renderer/realtime/wsBase.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export function getWsBaseUrl(): string {
|
||||
// 1) если ты пробрасываешь конфиг в window
|
||||
const w = window as any;
|
||||
if (w.__ENV__?.WS_BASE) return String(w.__ENV__.WS_BASE);
|
||||
|
||||
// 2) если открыто с https/http — строим ws/wss автоматически
|
||||
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||
const origin = window.location.origin; // http(s)://host
|
||||
return origin.replace(/^http/, 'ws');
|
||||
}
|
||||
|
||||
// 3) дефолт
|
||||
return 'wss://minecraft.api.popa-popa.ru';
|
||||
}
|
||||
12
src/renderer/theme/voiceStyles.ts
Normal file
12
src/renderer/theme/voiceStyles.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export const GRADIENT =
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||
|
||||
export const glassBox = {
|
||||
background:
|
||||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.12), transparent 55%),' +
|
||||
'radial-gradient(circle at 90% 20%, rgba(233,64,205,0.10), transparent 55%),' +
|
||||
'rgba(10,10,20,0.86)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
|
||||
};
|
||||
17
src/renderer/types/rooms.ts
Normal file
17
src/renderer/types/rooms.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export type RoomInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
users: string[]; // usernames
|
||||
maxUsers: number;
|
||||
};
|
||||
|
||||
export type RoomDetails = {
|
||||
id: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
max_users: number;
|
||||
users: number;
|
||||
usernames: string[];
|
||||
public: boolean;
|
||||
};
|
||||
27
src/renderer/utils/TrayBridge.tsx
Normal file
27
src/renderer/utils/TrayBridge.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function TrayBridge() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const onNavigate = (to: unknown) => {
|
||||
navigate(String(to));
|
||||
};
|
||||
|
||||
const onLogout = () => {
|
||||
localStorage.removeItem('launcher_config');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
window.electron.ipcRenderer.on('tray-navigate', onNavigate);
|
||||
window.electron.ipcRenderer.on('tray-logout', onLogout);
|
||||
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeAllListeners('tray-navigate');
|
||||
window.electron.ipcRenderer.removeAllListeners('tray-logout');
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
106
src/renderer/utils/itemTranslator.ts
Normal file
106
src/renderer/utils/itemTranslator.ts
Normal file
@ -0,0 +1,106 @@
|
||||
// utils/itemTranslator.ts
|
||||
|
||||
/* ----------------------------- */
|
||||
/* ENCHANT TRANSLATIONS */
|
||||
/* ----------------------------- */
|
||||
|
||||
export const ENCHANT_TRANSLATIONS: Record<string, string> = {
|
||||
sharpness: 'Острота',
|
||||
smite: 'Небесная кара',
|
||||
bane_of_arthropods: 'Бич членистоногих',
|
||||
efficiency: 'Эффективность',
|
||||
unbreaking: 'Прочность',
|
||||
fortune: 'Удача',
|
||||
silk_touch: 'Шёлковое касание',
|
||||
power: 'Сила',
|
||||
punch: 'Отдача',
|
||||
flame: 'Огонь',
|
||||
infinity: 'Бесконечность',
|
||||
protection: 'Защита',
|
||||
fire_protection: 'Огнестойкость',
|
||||
blast_protection: 'Взрывоустойчивость',
|
||||
projectile_protection: 'Защита от снарядов',
|
||||
feather_falling: 'Невесомость',
|
||||
respiration: 'Подводное дыхание',
|
||||
aqua_affinity: 'Подводник',
|
||||
thorns: 'Шипы',
|
||||
depth_strider: 'Подводная ходьба',
|
||||
frost_walker: 'Ледоход',
|
||||
mending: 'Починка',
|
||||
binding_curse: 'Проклятие несъёмности',
|
||||
vanishing_curse: 'Проклятие утраты',
|
||||
looting: 'Добыча',
|
||||
sweeping: 'Разящий клинок',
|
||||
fire_aspect: 'Заговор огня',
|
||||
knockback: 'Отдача',
|
||||
luck_of_the_sea: 'Морская удача',
|
||||
lure: 'Приманка',
|
||||
};
|
||||
|
||||
/* ----------------------------- */
|
||||
/* GENERIC META TRANSLATIONS */
|
||||
/* ----------------------------- */
|
||||
|
||||
export const META_TRANSLATIONS: Record<string, string> = {
|
||||
durability: 'Прочность',
|
||||
max_durability: 'Максимальная прочность',
|
||||
custom_model_data: 'Кастомная модель',
|
||||
unbreakable: 'Неразрушимый',
|
||||
repair_cost: 'Стоимость починки',
|
||||
hide_flags: 'Скрытые флаги',
|
||||
rarity: 'Редкость',
|
||||
damage: 'Урон',
|
||||
attack_speed: 'Скорость атаки',
|
||||
armor: 'Броня',
|
||||
armor_toughness: 'Твёрдость брони',
|
||||
knockback_resistance: 'Сопротивление отталкиванию',
|
||||
glowing: 'Подсветка',
|
||||
};
|
||||
|
||||
/* ----------------------------- */
|
||||
/* FORMATTERS */
|
||||
/* ----------------------------- */
|
||||
|
||||
export function translateEnchant(key: string): string {
|
||||
return ENCHANT_TRANSLATIONS[key.toLowerCase()] ?? beautifyKey(key);
|
||||
}
|
||||
|
||||
export function translateMetaKey(key: string): string {
|
||||
return META_TRANSLATIONS[key.toLowerCase()] ?? beautifyKey(key);
|
||||
}
|
||||
|
||||
export function beautifyKey(key: string): string {
|
||||
return key
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
}
|
||||
|
||||
/* ----------------------------- */
|
||||
/* VALUE FORMATTERS */
|
||||
/* ----------------------------- */
|
||||
|
||||
export function formatMetaValue(value: any): string {
|
||||
if (typeof value === 'boolean') return value ? 'Да' : 'Нет';
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (typeof value === 'string') return value;
|
||||
|
||||
if (Array.isArray(value)) return value.join(', ');
|
||||
if (typeof value === 'object') return 'Сложное значение';
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/* ----------------------------- */
|
||||
/* ENCHANT LIST HELPER */
|
||||
/* ----------------------------- */
|
||||
|
||||
export function formatEnchants(
|
||||
enchants?: Record<string, number>,
|
||||
): { label: string; level: number }[] {
|
||||
if (!enchants || typeof enchants !== 'object') return [];
|
||||
|
||||
return Object.entries(enchants).map(([key, level]) => ({
|
||||
label: translateEnchant(key),
|
||||
level,
|
||||
}));
|
||||
}
|
||||
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' };
|
||||
}
|
||||
}
|
||||
43
src/renderer/utils/pendingVerification.ts
Normal file
43
src/renderer/utils/pendingVerification.ts
Normal file
@ -0,0 +1,43 @@
|
||||
export type PendingVerification = {
|
||||
username: string;
|
||||
password?: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
const PENDING_KEY = 'pending_verifications_v1';
|
||||
|
||||
export const loadPending = (): PendingVerification[] => {
|
||||
try {
|
||||
const raw = localStorage.getItem(PENDING_KEY);
|
||||
return raw ? (JSON.parse(raw) as PendingVerification[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const savePending = (items: PendingVerification[]) => {
|
||||
localStorage.setItem(PENDING_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
export const upsertPending = (item: PendingVerification) => {
|
||||
const list = loadPending();
|
||||
const next = [
|
||||
item,
|
||||
...list.filter(
|
||||
(x) => x.username.toLowerCase() !== item.username.toLowerCase(),
|
||||
),
|
||||
].slice(0, 5);
|
||||
|
||||
savePending(next);
|
||||
};
|
||||
|
||||
export const removePending = (username: string) => {
|
||||
const list = loadPending();
|
||||
savePending(
|
||||
list.filter((x) => x.username.toLowerCase() !== username.toLowerCase()),
|
||||
);
|
||||
};
|
||||
|
||||
export const clearPending = () => {
|
||||
localStorage.removeItem(PENDING_KEY);
|
||||
};
|
||||
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