144 Commits

Author SHA1 Message Date
f617940c44 add head avatar to voice 2026-01-02 19:50:06 +05:00
53879dfaac Merge branch 'voice_test' of https://git.popa-popa.ru/DIKER/popa-launcher into voice_test 2026-01-02 19:22:20 +05:00
8520f2120d voice v2 2026-01-02 19:22:11 +05:00
4944a18076 add voice button in TopBar 2026-01-02 17:23:23 +05:00
a76a8b5656 voice v1.1 2026-01-02 16:23:26 +05:00
d90ef2e535 voice test 2026-01-02 15:54:58 +05:00
1348b39a4c fix 2026-01-01 22:43:07 +05:00
c77d173fce set v1.0.6 2026-01-01 19:42:52 +05:00
687e2db51b add neoforge modpacks support 2026-01-01 17:45:11 +05:00
5dc1744cfd add set description in sell item 2025-12-29 22:31:20 +05:00
614f6d0a25 set v1.0.5 2025-12-29 20:31:55 +05:00
8d9f55f601 fix marketplace 2025-12-29 20:21:46 +05:00
c449e99542 set v1.0.4 2025-12-29 19:17:50 +05:00
dc4fe3b18e add info for item 2025-12-29 19:17:08 +05:00
e0889cfaea add essential tooltip 2025-12-29 16:54:05 +05:00
b1f378c5a8 v1.0.3(add quick launch, redesign shop, add new directory game) 2025-12-29 16:49:27 +05:00
287116103d redesign minor 2025-12-29 10:04:58 +05:00
2777e9b008 minor redesign shop(завтра доделаю) 2025-12-29 00:04:23 +05:00
1a78b95524 add icon 2025-12-28 14:36:29 +05:00
4efa8c4437 stylize Notifier update 2025-12-28 14:32:39 +05:00
a4a10c164e Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer 2025-12-28 14:12:09 +05:00
8003e3567a add commands tab in shop 2025-12-28 14:12:07 +05:00
c5ea4ca38f fix build 2025-12-22 19:45:03 +05:00
7fbb9fa78a fix tray 2025-12-22 18:29:47 +05:00
6caa563b41 add websocket 2025-12-22 16:42:37 +05:00
4b8e535c58 redesign login/registration. add autologin from registration. add 'continue' register from accept acc 2025-12-21 01:15:53 +05:00
779f8f779d add promo to News 2025-12-20 21:12:52 +05:00
f8b358d9bd add new page promocode 2025-12-20 20:49:49 +05:00
5f23adc9ae add secret page 2025-12-20 19:32:47 +05:00
b1d369e49d Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer 2025-12-20 17:45:19 +05:00
acd46bb31c fix qr code auth 2025-12-20 17:45:16 +05:00
26f0865d2e minorfix 2025-12-20 17:32:22 +05:00
d25cda62df Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer 2025-12-20 17:11:25 +05:00
64b6129713 add qr code auth 2025-12-20 17:11:23 +05:00
6edf7ca7d0 redesign marketplace + fix marketplace 2025-12-20 17:06:46 +05:00
3aa99e7262 fix registration on production 2025-12-19 21:45:24 +05:00
fef89513c2 круто 2025-12-17 13:16:59 +05:00
24423173a6 minor redesign inventory + add functions 2025-12-17 00:12:23 +05:00
70ec57d6fb test fix authorization 2025-12-16 17:09:51 +05:00
c15c36891e add inventory and change cases 2025-12-16 15:30:40 +05:00
6db213d602 add tray and add settings 2025-12-16 00:30:00 +05:00
cd7ad5039e add settings, redesign settings panel 2025-12-15 22:41:38 +05:00
6adc64dab8 Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer 2025-12-15 00:47:20 +05:00
11a203cb8f new function settings 2025-12-15 00:37:45 +05:00
ff87c9d4a5 rework style TobBar in themes.ts 2025-12-14 23:49:32 +05:00
e93379ff12 Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer 2025-12-14 22:25:03 +05:00
28fc3ab0fb add theme provider 2025-12-14 22:25:01 +05:00
645de4248e minor fix 2025-12-14 22:22:05 +05:00
ae4a67dcdf ne minor, a ebat fix 2025-12-14 21:14:59 +05:00
de616ee8ac restyle change skin 2025-12-14 13:16:01 +05:00
d1e64382a4 add gradient to topbar, alert quests, restyle onlineplayerspanel 2025-12-14 13:12:01 +05:00
41c1ae3357 restyle TopBar 2025-12-13 23:38:10 +05:00
62fe32ea99 restyle TopBar 2025-12-13 23:17:14 +05:00
f6e295d157 refactor TopBar 2025-12-13 22:47:30 +05:00
1900a9d1e6 fix TopBar 2025-12-13 22:32:12 +05:00
ca8ac8e880 mnoga che sdelal 2025-12-13 22:17:17 +05:00
abb45c3838 fix i cheto sdelal 2025-12-13 20:14:24 +05:00
d9a3a1cd1f minor fix marketplace + xyi ego znaet 2025-12-13 18:59:30 +05:00
74a3e3c7cf Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer 2025-12-13 16:27:58 +05:00
7d7136bac9 fix overflow in daily rewards 2025-12-13 16:27:30 +05:00
9a0daa26ca pulsating day now 2025-12-13 16:25:25 +05:00
712ae70e2a axyenniu daily reward page 2025-12-13 16:18:47 +05:00
226f5c1393 Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer 2025-12-13 01:36:00 +05:00
eabc54680f add daily reward 2025-12-13 01:35:55 +05:00
bfb5a8ae6d minor fix front 2025-12-13 00:12:32 +05:00
5e5f1aaa0a add function hide password to login 2025-12-12 19:45:27 +05:00
ee706a3fb0 Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer 2025-12-12 16:18:24 +05:00
d7d126f01f fix padding top in Shop 2025-12-07 20:29:29 +05:00
23308c8598 restyle Shop components 2025-12-07 20:27:56 +05:00
14905fcee7 restyle Shop.tsx 2025-12-07 20:17:40 +05:00
833444df2e add bonuses in shop 2025-12-07 20:11:41 +05:00
3e03c1024d add cape preview in Shop 2025-12-07 19:59:57 +05:00
c6cceaf299 fix CaseRoulette 2025-12-07 18:45:56 +05:00
a456925a08 add component CoinsDisplay and rework CustomTooltip 2025-12-07 18:35:14 +05:00
52336f8960 new style CaseRoulette 2025-12-07 14:58:05 +05:00
bbd0dd11b0 Work CaseRoulette 2025-12-07 03:26:54 +05:00
39f8ec875b caseRoulette 50/50 2025-12-07 03:17:29 +05:00
c14315b078 add cases to Shop (roulette don't work :( ) 2025-12-07 02:44:25 +05:00
3ddcda2cec fix items in PlayerInventory 2025-12-07 00:43:32 +05:00
5efeb9a5c1 add OnlinePlayersPanel in Profile 2025-12-06 21:08:26 +05:00
6a7169e2ae new style VersionExplorer 2025-12-06 14:14:01 +05:00
2e6b2d7add add news page 2025-12-06 02:26:35 +05:00
3e62bd7c27 add scroll to TopBar buttons 2025-12-05 15:41:44 +05:00
48a0d0defb remove title in TopBar and add style scrollbar in App 2025-12-05 01:46:57 +05:00
8c9e46a1ae style linear progress in LaunchPage 2025-12-05 01:38:23 +05:00
215e3d6d39 rework circulars loaders 2025-12-05 01:29:06 +05:00
fc5e65f189 add loader to login 2025-12-05 01:01:13 +05:00
734ca4fce5 add normal progressbar and function to stop game 2025-12-05 00:22:43 +05:00
e8ec4052ba fix registration and logout button 2025-12-04 08:50:17 +05:00
fcbc2352dc stable launch 100% (yeeees) 2025-12-04 01:10:47 +05:00
5deba6ca92 stable launch minecraft 2025-12-04 00:53:42 +05:00
fd6bb8b4db add own cdn server 2025-12-04 00:13:45 +05:00
6665fca48d rework registration 2025-12-03 10:58:47 +05:00
205bb84fec new design register/login panel(inputs) 2025-12-02 23:30:23 +05:00
59c7d7fd85 maybe work 2025-12-02 04:19:19 +05:00
65ea5418da compact logs 2025-12-02 01:37:41 +05:00
5d660e7a95 add logout button 2025-07-21 23:08:11 +05:00
83a0e308bc fix: height in versionsExplorer 2025-07-21 22:50:26 +05:00
9746847ebf ya xyi znaet che pisat, zaebalsya pridymivat 2025-07-21 22:32:40 +05:00
f201aaa894 Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer 2025-07-21 17:42:25 +05:00
97c28c2b32 add 24 java version 2025-07-21 17:42:22 +05:00
30c25452dc feat: default steve skin 2025-07-21 17:13:55 +05:00
aae4261b53 fix: qrcode 2025-07-21 17:11:54 +05:00
3b13d78cdc Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer 2025-07-21 17:07:54 +05:00
d38faccf6f fix design marketplace 2025-07-21 17:07:49 +05:00
07caa7c53c Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer 2025-07-21 17:02:29 +05:00
a2f42346ae fix: qrcode 2025-07-21 17:02:26 +05:00
932d867505 paint buttons / minor change 2025-07-21 15:17:21 +05:00
212b58c072 add: registration page 2025-07-21 10:55:55 +05:00
3d78d3e279 feat: improved TopBar and empty registration page 2025-07-21 08:27:35 +05:00
e018aec8db debug minecraft-launcher.ts 2025-07-20 20:15:09 +05:00
fa115e0a6c fix: package, auth url 2025-07-20 03:15:13 +05:00
51b155e70a fix: AuthForm 50/50 2025-07-20 02:49:58 +05:00
6ee1b67315 add: marketplace 2025-07-19 04:40:46 +05:00
c39a8bc43c add: if player online when display marketplace in TopBar 2025-07-19 02:07:34 +05:00
56da7c7543 add: shop capes and refactor cape card 2025-07-19 01:36:33 +05:00
26f601635b add: capes switch in user profile 2025-07-18 20:14:44 +05:00
ec54219192 add: skin viewer, refatoring to api 2025-07-18 18:29:34 +05:00
f3aa32a60a add: display coins in TopBar 2025-07-18 01:30:39 +05:00
7938555c91 feat: your authorization 2025-07-18 00:51:08 +05:00
591e354dcb fix: names 2025-07-14 00:48:30 +05:00
ce141a014c remove loading in ServerStatus 2025-07-14 00:30:20 +05:00
3d4c9c89ef feat: rounded icons TopBar 2025-07-14 00:27:42 +05:00
90d4bb80e6 feat: custom minimizeIcon 2025-07-14 00:18:29 +05:00
fdf5c7a90d fix: styles TopBar 2025-07-14 00:12:14 +05:00
387d1548ba feat: update styles
add: TopBar title
2025-07-13 23:52:28 +05:00
942066ea76 feat: improve Minecraft version handling 2025-07-13 23:37:46 +05:00
815ce286f7 add: VersionExplorer, don't work first run Minecraft 2025-07-08 03:29:36 +05:00
31a26dc1ce add: auto update launcher 2025-07-08 02:30:35 +05:00
5cd483209f fix: fabric version detection 2025-07-07 14:21:57 +05:00
1b50a7d4e4 feat: improved launch settings 2025-07-07 06:56:30 +05:00
b14de1d15a feat: LaunchPage redisgned 2025-07-07 05:32:22 +05:00
7eaf7a7610 feat: saving files and folders when updating modpaks 2025-07-07 04:58:57 +05:00
76917e3f90 add: background, custom topbar 2025-07-07 04:41:17 +05:00
261b9ac253 add: server status 2025-07-07 02:53:21 +05:00
2eda1d7806 feat: add Fabric version update and improve launch params handling 2025-07-07 02:24:36 +05:00
1b496288de refactor: update Minecraft download logic with URL and args support 2025-07-07 01:45:03 +05:00
ff91303b18 add: second attemp to first run minecraft 2025-07-07 01:13:13 +05:00
8fa6956095 add: linear progress 2025-07-07 00:58:09 +05:00
6f92b2acad working authirization 2025-07-07 00:21:13 +05:00
b65b9538bb working version(without authorization) 2025-07-06 22:41:16 +05:00
4717132b05 Working version(some) 2025-07-06 22:13:09 +05:00
12f7ea8d1c refactoring, check auth 2025-07-05 19:48:05 +05:00
e21a51482a add: login page 2025-07-05 05:47:53 +05:00
106 changed files with 31295 additions and 2900 deletions

1
.cursorignore Normal file
View File

@ -0,0 +1 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)

View File

@ -1,7 +1,3 @@
/**
* Base webpack config used across other specific configs
*/
import webpack from 'webpack';
import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin';
import webpackPaths from './webpack.paths';
@ -20,7 +16,6 @@ const configuration: webpack.Configuration = {
use: {
loader: 'ts-loader',
options: {
// Remove this line to enable type checking in webpack builds
transpileOnly: true,
compilerOptions: {
module: 'nodenext',
@ -34,18 +29,22 @@ const configuration: webpack.Configuration = {
output: {
path: webpackPaths.srcPath,
// https://github.com/webpack/webpack/issues/1114
library: { type: 'commonjs2' },
},
/**
* Determine the array of extensions that should be used to resolve modules.
*/
resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
modules: [webpackPaths.srcPath, 'node_modules'],
// There is no need to add aliases here, the paths in tsconfig get mirrored
plugins: [new TsconfigPathsPlugins()],
// Новые настройки
extensionAlias: {
'.js': ['.js', '.mjs'],
},
fullySpecified: false,
alias: {
'undici/lib/core/util': 'undici/lib/core/util.js',
},
},
plugins: [new webpack.EnvironmentPlugin({ NODE_ENV: 'production' })],

View File

@ -36,9 +36,15 @@ const configuration: webpack.Configuration = {
},
optimization: {
minimize: false,
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
ecma: 2020,
keep_classnames: true,
keep_fnames: true,
},
}),
],
},

View File

@ -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: [

View File

@ -88,6 +88,13 @@ const configuration: webpack.Configuration = {
'file-loader',
],
},
{
test: /\.(mp3|wav|ogg)$/i,
type: 'asset/resource',
generator: {
filename: 'assets/sounds/[name][ext]',
},
}
],
},

3
.gitignore vendored
View File

@ -1,3 +1,5 @@
public/
# Logs
logs
*.log
@ -27,3 +29,4 @@ npm-debug.log.*
*.css.d.ts
*.sass.d.ts
*.scss.d.ts
.env

View File

@ -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}
/>

View File

1745
assets/fonts/benzin-bold.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 13 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

30
assets/images/heart.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.7 KiB

9535
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/electron-react-boilerplate/electron-react-boilerplate.git"
"url": "git+https://git.popa-popa.ru/DIKER/popa-launcher.git"
},
"license": "MIT",
"author": {
@ -42,18 +42,22 @@
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish always && npm run build:dll",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.dev.ts",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer",
"start:main": "concurrently -k -P \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon . -- {@}\" --",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"test": "jest"
"test": "jest",
"publish-debug": "electron-builder build --publish always"
},
"browserslist": [
"extends browserslist-config-erb"
],
"overrides": {
"undici": "6.10.2"
},
"prettier": {
"singleQuote": true,
"overrides": [
@ -101,18 +105,41 @@
}
},
"dependencies": {
"@electron/notarize": "^3.0.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.2.0",
"@mui/material": "^7.2.0",
"@xmcl/core": "^2.14.1",
"@xmcl/file-transfer": "^2.0.3",
"@xmcl/installer": "^6.1.2",
"@xmcl/resourcepack": "^1.2.4",
"@xmcl/user": "^4.2.0",
"easymde": "^2.20.0",
"electron-debug": "^4.1.0",
"electron-log": "^5.3.2",
"electron-updater": "^6.3.9",
"find-java-home": "^2.0.0",
"https-browserify": "^1.0.0",
"path-browserify": "^1.0.1",
"qr-code-styling": "^1.9.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.3.0"
"react-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",
"util": "^0.12.5",
"uuid": "^11.1.0"
},
"devDependencies": {
"@electron/rebuild": "^3.7.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.12.9",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
@ -121,6 +148,7 @@
"@types/react": "^19.0.11",
"@types/react-dom": "^19.0.4",
"@types/react-test-renderer": "^19.0.0",
"@types/three": "^0.178.1",
"@types/webpack-bundle-analyzer": "^4.7.0",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
@ -175,10 +203,9 @@
"webpack-merge": "^6.0.1"
},
"build": {
"productName": "ElectronReact",
"productName": "popa-launcher",
"appId": "org.erb.ElectronReact",
"asar": true,
"afterSign": ".erb/scripts/notarize.js",
"asarUnpack": "**\\*.{node,dll}",
"files": [
"dist",
@ -234,9 +261,12 @@
"./assets/**"
],
"publish": {
"provider": "github",
"owner": "electron-react-boilerplate",
"repo": "electron-react-boilerplate"
"provider": "generic",
"url": "https://git.popa-popa.ru/DIKER/popa-launcher/releases/download/latest",
"channel": "latest",
"requestHeaders": {
"Authorization": "token ${env.GH_TOKEN}"
}
}
},
"collective": {

View File

@ -1,12 +1,12 @@
{
"name": "electron-react-boilerplate",
"version": "4.6.0",
"name": "popa-launcher",
"version": "1.0.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "electron-react-boilerplate",
"version": "4.6.0",
"name": "popa-launcher",
"version": "1.0.6",
"hasInstallScript": true,
"license": "MIT"
}

View File

@ -1,12 +1,12 @@
{
"name": "electron-react-boilerplate",
"version": "4.6.0",
"description": "A foundation for scalable desktop apps",
"name": "popa-launcher",
"version": "1.0.6",
"description": "Popa Launcher",
"license": "MIT",
"author": {
"name": "Electron React Boilerplate Maintainers",
"email": "electronreactboilerplate@gmail.com",
"url": "https://github.com/electron-react-boilerplate"
"name": "DIKER",
"email": "diker0k@gmail.com",
"url": "https://github.com/DIKER0K"
},
"main": "./dist/main/main.js",
"scripts": {

98
src/main/auth-service.ts Normal file
View File

@ -0,0 +1,98 @@
import { YggdrasilClient, YggrasilAuthentication } from '@xmcl/user';
import { v4 as uuidv4 } from 'uuid';
import { API_BASE_URL } from '../renderer/api';
// Ely.by сервер
const ELY_BY_AUTH_SERVER = API_BASE_URL;
export class AuthService {
private client: YggdrasilClient;
constructor() {
this.client = new YggdrasilClient(ELY_BY_AUTH_SERVER);
}
async login(
username: string,
password: string,
): Promise<YggrasilAuthentication> {
try {
// Генерируем уникальный clientToken
const clientToken = uuidv4();
// Выполняем запрос напрямую к правильному URL
const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/authenticate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
clientToken,
requestUser: true,
}),
});
if (!response.ok) {
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();
console.log(`Аутентификация успешна для ${auth.selectedProfile?.name}`);
return auth;
} catch (error) {
console.error('Ошибка при авторизации:', error);
throw error;
}
}
async validate(accessToken: string, clientToken: string): Promise<boolean> {
try {
console.log(accessToken, clientToken);
const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ accessToken, clientToken }),
});
return response.ok;
} catch (error) {
console.error('Ошибка при валидации токена:', error);
return false;
}
}
async refresh(
accessToken: string,
clientToken: string,
): Promise<YggrasilAuthentication | null> {
try {
const response = await fetch(`${ELY_BY_AUTH_SERVER}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ accessToken, clientToken }),
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error('Ошибка при обновлении токена:', error);
return null;
}
}
}

View File

@ -9,22 +9,177 @@
* `./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';
import { resolveHtmlPath } from './util';
import {
initMinecraftHandlers,
initAuthHandlers,
initServerStatusHandler,
initPackConfigHandlers,
} from './minecraft-launcher';
class AppUpdater {
constructor() {
log.transports.file.level = 'info';
autoUpdater.logger = log;
const server = 'https://git.popa-popa.ru/DIKER/popa-launcher';
// Для Gitea нужно указать конкретную структуру URL
// Обратите внимание на использование пути /download/
autoUpdater.setFeedURL({
provider: 'generic',
url: `${server}/releases/download/latest`, // Укажите конкретную версию
channel: 'latest',
});
// Проверка обновлений
autoUpdater.checkForUpdatesAndNotify();
// Периодическая проверка обновлений (каждый час)
setInterval(
() => {
autoUpdater.checkForUpdatesAndNotify();
},
60 * 60 * 1000,
);
// Обработчики событий обновления
autoUpdater.on('update-downloaded', () => {
log.info('Обновление загружено. Будет установлено при перезапуске.');
// Можно отправить событие в renderer для уведомления пользователя
if (mainWindow) {
mainWindow.webContents.send('update-available');
}
});
}
}
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));
@ -43,6 +198,16 @@ if (isDebug) {
require('electron-debug').default();
}
ipcMain.handle('close-app', () => {
app.quit();
return true;
});
ipcMain.handle('minimize-app', () => {
mainWindow?.minimize();
return true;
});
const installExtensions = async () => {
const installer = require('electron-devtools-installer');
const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
@ -56,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();
@ -65,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);
};
@ -72,15 +267,26 @@ const createWindow = async () => {
mainWindow = new BrowserWindow({
show: false,
width: 1024,
height: 728,
icon: getAssetPath('icon.png'),
height: 850,
autoHideMenuBar: true,
resizable: false,
frame: false,
icon: getAssetPath('popa-popa.png'),
webPreferences: {
webSecurity: false,
preload: app.isPackaged
? path.join(__dirname, 'preload.js')
: path.join(__dirname, '../../.erb/dll/preload.js'),
},
});
mainWindow.on('close', (e) => {
if (!isQuitting && launcherSettings.closeToTray) {
e.preventDefault();
mainWindow?.hide();
}
});
mainWindow.loadURL(resolveHtmlPath('index.html'));
mainWindow.on('ready-to-show', () => {
@ -90,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);
}
});
@ -110,6 +319,11 @@ const createWindow = async () => {
// Remove this if your app does not use auto updates
// eslint-disable-next-line
new AppUpdater();
initAuthHandlers();
initMinecraftHandlers();
initServerStatusHandler();
initPackConfigHandlers();
};
/**
@ -124,6 +338,10 @@ app.on('window-all-closed', () => {
}
});
app.on('before-quit', () => {
isQuitting = true;
});
app
.whenReady()
.then(() => {
@ -135,3 +353,13 @@ app
});
})
.catch(console.log);
ipcMain.handle('install-update', () => {
autoUpdater.quitAndInstall();
});
ipcMain.handle('auth-changed', (_e, payload: { isAuthed: boolean }) => {
isAuthed = Boolean(payload?.isAuthed);
buildTrayMenu();
return true;
});

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,29 @@
// Disable no-unused-vars, broken for spread args
/* eslint no-unused-vars: off */
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
export type Channels = 'ipc-example';
export type Channels =
| 'ipc-example'
| 'download-progress'
| 'launch-minecraft'
| 'installation-status'
| 'get-server-status'
| 'close-app'
| 'minimize-app'
| 'save-pack-config'
| 'load-pack-config'
| 'update-available'
| 'install-update'
| 'get-installed-versions'
| 'get-available-versions'
| 'minecraft-log'
| 'minecraft-error'
| 'overall-progress'
| 'stop-minecraft'
| 'minecraft-started'
| 'apply-launcher-settings'
| 'tray-navigate'
| 'tray-logout'
| 'auth-changed'
| 'minecraft-stopped';
const electronHandler = {
ipcRenderer: {
@ -21,6 +42,12 @@ const electronHandler = {
once(channel: Channels, func: (...args: unknown[]) => void) {
ipcRenderer.once(channel, (_event, ...args) => func(...args));
},
invoke(channel: Channels, ...args: unknown[]): Promise<any> {
return ipcRenderer.invoke(channel, ...args);
},
removeAllListeners(channel: Channels) {
ipcRenderer.removeAllListeners(channel);
},
},
};

View File

@ -1,62 +1,142 @@
/*
* @NOTE: Prepend a `~` to css file paths that are in your node_modules
* See https://github.com/webpack-contrib/sass-loader#imports
*/
@font-face {
font-family: 'Benzin-Bold';
src: url('../../assets/fonts/benzin-bold.eot'); /* IE 9 Compatibility Mode */
src:
url('../../assets/fonts/benzin-bold.eot?#iefix') format('embedded-opentype'),
/* IE < 9 */ url('../../assets/fonts/benzin-bold.woff2') format('woff2'),
/* Super Modern Browsers */ url('../../assets/fonts/benzin-bold.woff')
format('woff'),
/* Firefox >= 3.6, any other modern browser */
url('../../assets/fonts/benzin-bold.ttf') format('truetype'),
/* Safari, Android, iOS */
url('../../assets/fonts/benzin-bold.svg#benzin-bold') format('svg'); /* Chrome < 4, Legacy iOS */
}
/* 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(
200.96deg,
#fedc2a -29.09%,
#dd5789 51.77%,
#7a2c9e 129.35%
);
font-family: sans-serif;
overflow-y: hidden;
background: linear-gradient(242.94deg, #000000 39.07%, #3b4187 184.73%);
font-family: 'Benzin-Bold' !important;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
margin: 0;
user-select: none;
}
button {
background-color: white;
padding: 10px 20px;
p {
font-family: 'Benzin-Bold' !important;
}
h1 {
font-family: 'Benzin-Bold' !important;
}
h2 {
font-family: 'Benzin-Bold' !important;
}
h3 {
font-family: 'Benzin-Bold' !important;
}
h4 {
font-family: 'Benzin-Bold' !important;
}
h5 {
font-family: 'Benzin-Bold' !important;
}
h6 {
font-family: 'Benzin-Bold' !important;
}
span {
font-family: 'Benzin-Bold' !important;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
/* трек */
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.08);
border-radius: 100px;
margin: 20px 0; /* ⬅– отступы сверху и снизу */
}
/* Бегунок */
::-webkit-scrollbar-thumb {
background: linear-gradient(71deg, #f27121 0%, #e940cd 70%, #8a2387 100%);
border-radius: 10px;
border: none;
appearance: none;
font-size: 1.3rem;
box-shadow: 0px 8px 28px -6px rgba(24, 39, 75, 0.12),
0px 18px 88px -4px rgba(24, 39, 75, 0.14);
transition: all ease-in 0.1s;
cursor: pointer;
opacity: 0.9;
}
button:hover {
transform: scale(1.05);
opacity: 1;
/* hover эффект */
::-webkit-scrollbar-thumb:hover {
background-size: 400% 400%;
animation-duration: 1.7s;
}
li {
list-style: none;
}
a {
text-decoration: none;
height: fit-content;
width: fit-content;
margin: 10px;
}
a:hover {
opacity: 1;
text-decoration: none;
}
.Hello {
display: flex;
justify-content: center;
align-items: center;
margin: 20px 0;
/* shimmer-анимация градиента */
@keyframes scrollbarShimmer {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}

View File

@ -1,50 +1,435 @@
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
import icon from '../../assets/icon.svg';
import {
MemoryRouter as Router,
Routes,
Route,
Navigate,
useNavigate,
} from 'react-router-dom';
import Login from './pages/Login';
import LaunchPage from './pages/LaunchPage';
import { ReactNode, useEffect, useState } from 'react';
import './App.css';
import TopBar from './components/TopBar';
import { Box } from '@mui/material';
import MinecraftBackground from './components/MinecraftBackground';
import { Notifier } from './components/Notifier';
import { VersionsExplorer } from './pages/VersionsExplorer';
import Profile from './pages/Profile';
import Shop from './pages/Shop';
import Marketplace from './pages/Marketplace';
import { Registration } from './pages/Registration';
import { FullScreenLoader } from './components/FullScreenLoader';
import { News } from './pages/News';
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';
function Hello() {
return (
<div>
<div className="Hello">
<img width="200" alt="icon" src={icon} />
</div>
<h1>electron-react-boilerplate</h1>
<div className="Hello">
<a
href="https://electron-react-boilerplate.js.org/"
target="_blank"
rel="noreferrer"
>
<button type="button">
<span role="img" aria-label="books">
📚
</span>
Read our docs
</button>
</a>
<a
href="https://github.com/sponsors/electron-react-boilerplate"
target="_blank"
rel="noreferrer"
>
<button type="button">
<span role="img" aria-label="folded hands">
🙏
</span>
Donate
</button>
</a>
</div>
</div>
);
}
const AuthCheck = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
const checkAuth = async () => {
try {
const savedConfig = localStorage.getItem('launcher_config');
if (!savedConfig) {
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);
setIsAuthenticated(false);
}
};
checkAuth();
}, []);
const validateToken = async (accessToken: string, clientToken: string) => {
try {
// Используем IPC для валидации токена через main процесс
const result = await window.electron.ipcRenderer.invoke(
'validate-token',
{ accessToken, clientToken },
);
// Если токен недействителен, очищаем сохраненные данные в localStorage
if (!result.valid) {
console.log(
'Токен недействителен, очищаем данные авторизации из localStorage',
);
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
const config = JSON.parse(savedConfig);
// Сохраняем только логин и другие настройки, но удаляем токены
const cleanedConfig = {
username: config.username,
memory: config.memory || 4096,
comfortVersion: config.comfortVersion || '',
password: '', // Очищаем пароль для безопасности
};
localStorage.setItem(
'launcher_config',
JSON.stringify(cleanedConfig),
);
}
}
return result.valid;
} catch (error) {
console.error('Ошибка проверки токена:', error);
return false;
}
};
if (isAuthenticated === null) {
return <FullScreenLoader message="Проверка авторизации..." />;
}
return isAuthenticated ? children : <Navigate to="/login" replace />;
};
const App = () => {
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 ['/'];
}
};
export default function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Hello />} />
</Routes>
<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');
if (savedConfig) {
const config = JSON.parse(savedConfig);
if (config.username) {
setUsername(config.username);
}
}
}, []);
useEffect(() => {
const raw = localStorage.getItem('launcher_config');
const isAuthed = !!raw && !!JSON.parse(raw).accessToken;
window.electron.ipcRenderer.invoke('auth-changed', { isAuthed });
}, []);
return (
<Box
sx={{
width: '100vw',
height: '100vh',
position: 'relative',
overflow: 'hidden',
}}
>
{/* ФОН — НЕ масштабируется */}
<Box
sx={{ position: 'fixed', inset: 0, zIndex: 0, pointerEvents: 'none' }}
>
<MinecraftBackground />
</Box>
{/* UI — масштабируется */}
<Box
id="app-scroll"
sx={{
position: 'relative',
zIndex: 1,
height: '100%',
width: '100%',
overflowY: 'hidden',
overflowX: 'hidden',
}}
>
<Box
id="app-ui"
sx={{
position: 'relative',
zIndex: 1,
height: '100vh',
width: '100vw',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent:
path === '/profile' ||
path.startsWith('/launch') ||
path === '/login' ||
path === '/registration'
? 'center'
: 'flex-start',
overflowX: 'hidden',
}}
>
<TopBar onRegister={handleRegister} username={username || ''} />
<PageHeader />
<Notifier />
<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>
</Box>
);
};
export default App;

1473
src/renderer/api.ts Normal file

File diff suppressed because it is too large Load Diff

View 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();
};

View 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();
}

View 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();
}

View File

@ -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;
}

View 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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Binary file not shown.

View 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;

View File

@ -0,0 +1,208 @@
import React, { useMemo } from 'react';
import { Box, Typography, Paper, Chip, Button } from '@mui/material';
import CustomTooltip from './Notifications/CustomTooltip';
export interface CapeCardProps {
cape: {
cape_id?: string;
id?: string;
cape_name?: string;
name?: string;
cape_description?: string;
description?: string;
image_url: string;
is_active?: boolean;
price?: number;
purchased_at?: string;
};
mode: 'profile' | 'shop';
onAction: (capeId: string) => void;
actionDisabled?: boolean;
}
const GRADIENT =
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
export default function CapeCard({
cape,
mode,
onAction,
actionDisabled = false,
}: CapeCardProps) {
const capeId = cape.cape_id || cape.id || '';
const capeName = cape.cape_name || cape.name || 'Без названия';
const capeDescription = cape.cape_description || cape.description || '';
const action = useMemo(() => {
if (mode === 'shop') {
return { text: 'Купить', variant: 'gradient' as const };
}
return cape.is_active
? { text: 'Снять', variant: 'danger' as const }
: { text: 'Надеть', variant: 'success' as const };
}, [mode, cape.is_active]);
const topRightChip =
mode === 'shop' && cape.price !== undefined
? `${cape.price} коинов`
: cape.is_active
? 'Активен'
: undefined;
return (
<CustomTooltip arrow title={capeDescription} placement="bottom">
<Paper
elevation={0}
sx={{
width: '16.5vw',
borderRadius: '1.2vw',
overflow: 'hidden',
position: 'relative',
color: 'white',
background:
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
backdropFilter: 'blur(14px)',
transition:
'transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease',
'&:hover': {
// transform: 'scale(1.01)',
borderColor: 'rgba(200, 33, 242, 0.35)',
boxShadow: '0 1.2vw 3.2vw rgba(53, 3, 66, 0.75)',
},
}}
>
{/* градиентная полоска-акцент (как у твоих блоков) */}
<Box
sx={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '0.35vw',
background: GRADIENT,
opacity: 0.9,
pointerEvents: 'none',
zIndex: 2,
}}
/>
{/* chip справа сверху */}
{topRightChip && (
<Chip
label={topRightChip}
size="small"
sx={{
position: 'absolute',
top: '0.8vw',
right: '0.8vw',
zIndex: 3,
height: '1.55rem',
fontSize: '0.72rem',
fontWeight: 900,
color: 'white',
borderRadius: '999px',
background:
mode === 'shop'
? 'rgba(0,0,0,0.65)'
: 'linear-gradient(120deg, rgba(242,113,33,0.22), rgba(233,64,205,0.14), rgba(138,35,135,0.18))',
border: '1px solid rgba(255,255,255,0.10)',
backdropFilter: 'blur(12px)',
}}
/>
)}
{/* preview */}
<Box
sx={{
px: '1.1vw',
pt: '1.0vw',
pb: '0.7vw',
display: 'flex',
justifyContent: 'center',
}}
>
<Box
sx={{
borderRadius: '1.0vw',
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.10)',
background: 'rgba(255,255,255,0.04)',
maxHeight: '21vw',
}}
>
{/* Здесь показываем ЛЕВУЮ половину текстуры (лицевую часть) */}
<Box
sx={{
width: '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>
);
}

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View File

@ -0,0 +1,205 @@
import { useState, useEffect } from 'react';
import {
Box,
Checkbox,
Typography,
List,
ListItem,
ListItemIcon,
ListItemText,
Collapse,
} from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { FullScreenLoader } from '../components/FullScreenLoader';
interface FileNode {
name: string;
path: string;
isDirectory: boolean;
children: FileNode[];
}
interface FilesSelectorProps {
packName: string;
onSelectionChange: (selectedFiles: string[]) => void;
initialSelected?: string[]; // Добавляем этот параметр
}
export default function FilesSelector({
packName,
onSelectionChange,
initialSelected = [], // Значение по умолчанию
}: FilesSelectorProps) {
const [files, setFiles] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Используем initialSelected для начального состояния
const [selectedFiles, setSelectedFiles] = useState<string[]>(initialSelected);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(),
);
useEffect(() => {
const fetchFiles = async () => {
try {
setLoading(true);
const result = await window.electron.ipcRenderer.invoke(
'get-pack-files',
packName,
);
if (result.success) {
setFiles(result.files);
} else {
setError(result.error);
}
} catch (err) {
setError('Ошибка при загрузке файлов');
} finally {
setLoading(false);
}
};
fetchFiles();
}, [packName]);
// Обработка выбора файла/папки
const handleToggle = (
path: string,
isDirectory: boolean,
children: FileNode[],
) => {
let newSelected = [...selectedFiles];
if (isDirectory) {
if (selectedFiles.includes(path)) {
// Если папка выбрана, убираем ее и все вложенные файлы
newSelected = newSelected.filter((p) => !p.startsWith(path));
} else {
// Если папка не выбрана, добавляем ее и все вложенные файлы
newSelected.push(path);
const addChildPaths = (nodes: FileNode[]) => {
for (const node of nodes) {
newSelected.push(node.path);
if (node.isDirectory) {
addChildPaths(node.children);
}
}
};
addChildPaths(children);
}
} else {
// Для обычного файла просто переключаем состояние
if (selectedFiles.includes(path)) {
newSelected = newSelected.filter((p) => p !== path);
} else {
newSelected.push(path);
}
}
setSelectedFiles(newSelected);
onSelectionChange(newSelected);
};
// Переключение раскрытия папки
const toggleFolder = (path: string) => {
const newExpanded = new Set(expandedFolders);
if (expandedFolders.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedFolders(newExpanded);
};
// Рекурсивный компонент для отображения файлов и папок
const renderFileTree = (nodes: FileNode[]) => {
// Сортировка: сначала папки, потом файлы
const sortedNodes = [...nodes].sort((a, b) => {
// Если у элементов разные типы (папка/файл)
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1; // Папки идут первыми
}
// Если оба элемента одного типа, сортируем по алфавиту
return a.name.localeCompare(b.name);
});
return (
<List dense>
{sortedNodes.map((node) => (
<div key={node.path}>
<ListItem
sx={{
borderRadius: '3vw',
backgroundColor: '#FFFFFF1A',
marginBottom: '1vh',
}}
>
<ListItemIcon>
<Checkbox
edge="start"
checked={selectedFiles.includes(node.path)}
onChange={() =>
handleToggle(node.path, node.isDirectory, node.children)
}
tabIndex={-1}
sx={{ color: 'white' }}
/>
</ListItemIcon>
{node.isDirectory && (
<ListItemIcon onClick={() => toggleFolder(node.path)}>
{expandedFolders.has(node.path) ? (
<ExpandLessIcon sx={{ color: 'white' }} />
) : (
<ExpandMoreIcon sx={{ color: 'white' }} />
)}
</ListItemIcon>
)}
<ListItemIcon>
{node.isDirectory ? (
<FolderIcon sx={{ color: 'white' }} />
) : (
<InsertDriveFileIcon sx={{ color: 'white' }} />
)}
</ListItemIcon>
<ListItemText
primary={node.name}
sx={{ color: 'white', fontFamily: 'Benzin-Bold' }}
/>
</ListItem>
{node.isDirectory && node.children.length > 0 && (
<Collapse
in={expandedFolders.has(node.path)}
timeout="auto"
unmountOnExit
>
<Box sx={{ pl: 4 }}>{renderFileTree(node.children)}</Box>
</Collapse>
)}
</div>
))}
</List>
);
};
if (loading) {
return <FullScreenLoader fullScreen={false} />;
}
if (error) {
return <Typography color="error">{error}</Typography>;
}
return (
<Box sx={{ maxHeight: '300px', overflow: 'auto' }}>
{renderFileTree(files)}
</Box>
);
}

View 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>
);
};

View 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;

View 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,
}}
/>
);
};

View File

@ -0,0 +1,139 @@
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: {
username: string;
password: string;
};
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onLogin: () => void;
}
const AuthForm = ({ config, handleInputChange, onLogin }: AuthFormProps) => {
const [showPassword, setShowPassword] = useState(false);
const navigate = useNavigate();
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1.5vw',
alignItems: 'center',
}}
>
<GradientTextField
label="Никнейм"
required
name="username"
value={config.username}
onChange={handleInputChange}
sx={{
mt: '2.5vw',
mb: '0vw',
}}
/>
<GradientTextField
label="Пароль"
required
type={showPassword ? 'text' : 'password'}
name="password"
value={config.password}
onChange={handleInputChange}
sx={{
'& .MuiInputBase-input': {
color: 'white',
padding: '1rem 0.7rem 1.1rem 1.5rem',
fontFamily: 'Benzin-Bold',
},
}}
InputProps={{
endAdornment: (
<InputAdornment position="end" sx={{ margin: '0' }}>
<IconButton
disableRipple
disableFocusRipple
disableTouchRipple
onClick={() => setShowPassword((prev) => !prev)}
edge="end"
sx={{
color: 'white',
margin: '0',
padding: '0',
'& MuiTouchRipple-root css-r3djoj-MuiTouchRipple-root': {
display: 'none',
},
}}
>
<GradientVisibilityToggleIcon
crossed={showPassword} // когда type="text" -> перечеркнуть
sx={{ fontSize: '2.5vw', mr: '0.5vw' }}
/>
</IconButton>
</InputAdornment>
),
}}
/>
<Button
onClick={onLogin}
variant="contained"
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>
);
};
export default AuthForm;

View File

@ -0,0 +1,188 @@
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 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 (
<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)} Рекомендуем: 48 GB для большинства сборок
</Typography>
</Box>
);
};
export default MemorySlider;

View 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} />;
};

View File

@ -0,0 +1,110 @@
import { Box } from '@mui/material';
import heart from '../../../assets/images/heart.svg';
export default function MinecraftBackground() {
return (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: 0.25,
overflow: 'hidden',
zIndex: -10,
}}
>
<Box
sx={{
position: 'absolute',
bottom: 0,
right: 0,
gap: '1vw',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
rotate: '-20deg',
paddingTop: '30vw',
}}
>
<img
src={heart}
draggable={false}
style={{
width: '20vw',
height: '20vw',
rotate: '-20deg',
userSelect: 'none',
}}
/>
<img
src={heart}
draggable={false}
style={{
width: '20vw',
height: '20vw',
paddingBottom: '5vw',
userSelect: 'none',
}}
/>
<img
src={heart}
draggable={false}
style={{
width: '20vw',
height: '20vw',
rotate: '20deg',
userSelect: 'none',
}}
/>
</Box>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
gap: '1vw',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
rotate: '160deg',
paddingTop: '80vw',
}}
>
<img
src={heart}
draggable={false}
style={{
width: '20vw',
height: '20vw',
rotate: '-20deg',
userSelect: 'none',
}}
/>
<img
src={heart}
draggable={false}
style={{
width: '20vw',
height: '20vw',
paddingBottom: '5vw',
userSelect: 'none',
}}
/>
<img
src={heart}
draggable={false}
style={{
width: '20vw',
height: '20vw',
rotate: '20deg',
userSelect: 'none',
}}
/>
</Box>
</Box>
);
}

View 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>
);
}

View 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>;
}

View File

@ -0,0 +1,155 @@
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<Severity>('info');
const [hasUpdateAvailable, setHasUpdateAvailable] = useState(false);
useEffect(() => {
// Слушаем событие о наличии обновления
window.electron.ipcRenderer.on('update-available', () => {
setMessage('Доступно новое обновление');
setSeverity('info');
setHasUpdateAvailable(true);
setOpen(true);
});
return () => {
// Отписываемся от события при размонтировании
window.electron.ipcRenderer.removeAllListeners('update-available');
};
}, []);
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
setMessage('Доступно новое обновление (dev preview)');
setSeverity('info');
setHasUpdateAvailable(true);
setOpen(true);
}
}, []);
const handleClose = () => {
setOpen(false);
};
const handleUpdate = () => {
window.electron.ipcRenderer.invoke('install-update');
setOpen(false);
};
return (
<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',
}}
>
<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>
);
};

View 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>
);
};

View 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>
);
}

View File

@ -0,0 +1,570 @@
// src/renderer/components/PlayerInventory.tsx
import React, { useEffect, useImperativeHandle, useState } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardMedia,
CardContent,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Alert,
} from '@mui/material';
import {
RequestPlayerInventory,
getPlayerInventory,
sellItem,
PlayerInventoryItem,
} from '../api';
import { FullScreenLoader } from './FullScreenLoader';
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;
serverIp: string;
onSellSuccess?: () => void; // Callback для обновления маркетплейса после продажи
}
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[]>(
[],
);
const [error, setError] = useState<string | null>(null);
const [sellDialogOpen, setSellDialogOpen] = useState<boolean>(false);
const [selectedItem, setSelectedItem] = useState<PlayerInventoryItem | null>(
null,
);
const [price, setPrice] = useState<number>(0);
const [amount, setAmount] = useState<number>(1);
const [sellLoading, setSellLoading] = useState<boolean>(false);
const [sellError, setSellError] = useState<string | null>(null);
const [description, setDescription] = useState('');
// Функция для запроса инвентаря игрока
const fetchPlayerInventory = async () => {
try {
setLoading(true);
setError(null);
// Сначала делаем запрос на получение идентификатора запроса инвентаря
const inventoryRequest = await RequestPlayerInventory(serverIp, username);
const requestId = inventoryRequest.request_id;
// Затем начинаем опрашивать API для получения результата
let inventoryData = null;
let attempts = 0;
const maxAttempts = 10; // Максимальное количество попыток
while (!inventoryData && attempts < maxAttempts) {
attempts++;
try {
// Пауза перед следующим запросом
await new Promise((resolve) => setTimeout(resolve, 1000));
// Запрашиваем состояние инвентаря
const response = await getPlayerInventory(requestId);
// Если инвентарь загружен, сохраняем его
if (response.status === 'completed') {
inventoryData = response.result.inventory_data;
break;
}
} catch (e) {
console.log('Ожидание завершения запроса инвентаря...');
}
}
if (inventoryData) {
setInventoryItems(inventoryData);
} else {
setError('Не удалось получить инвентарь. Попробуйте еще раз.');
}
} catch (e) {
console.error('Ошибка при получении инвентаря:', e);
setError('Произошла ошибка при загрузке инвентаря.');
} finally {
setLoading(false);
}
};
useImperativeHandle(ref, () => ({
refresh: async () => {
await fetchPlayerInventory();
},
}));
// Открываем диалог для продажи предмета
const handleOpenSellDialog = (item: PlayerInventoryItem) => {
setSelectedItem(item);
setAmount(1);
setPrice(0);
setSellError(null);
setSellDialogOpen(true);
};
// Закрываем диалог
const handleCloseSellDialog = () => {
setSellDialogOpen(false);
setSelectedItem(null);
};
// Выставляем предмет на продажу
const handleSellItem = async () => {
if (!selectedItem) return;
try {
setSellLoading(true);
setSellError(null);
// Проверяем валидность введенных данных
if (price <= 0) {
setSellError('Цена должна быть больше 0');
return;
}
if (amount <= 0 || amount > selectedItem.amount) {
setSellError(`Количество должно быть от 1 до ${selectedItem.amount}`);
return;
}
// Отправляем запрос на продажу
const result = await sellItem(
username,
selectedItem.slot,
amount,
price,
serverIp,
description,
);
// Проверяем статус операции
if (result.status === 'pending') {
// Закрываем диалог и обновляем инвентарь
handleCloseSellDialog();
// Показываем уведомление о том, что операция обрабатывается
// setNotification({ // Assuming setNotification is available in the context
// open: true,
// message: 'Предмет выставляется на продажу. Это может занять некоторое время.',
// type: 'info'
// });
// Через 5 секунд обновляем инвентарь
setTimeout(() => {
fetchPlayerInventory();
// Вызываем callback для обновления маркетплейса
if (onSellSuccess) {
onSellSuccess();
}
}, 5000);
}
} catch (e) {
console.error('Ошибка при продаже предмета:', e);
setSellError('Произошла ошибка при продаже предмета.');
} finally {
setSellLoading(false);
}
};
// Загружаем инвентарь при монтировании компонента
useEffect(() => {
fetchPlayerInventory();
}, [username, serverIp]);
// Получаем отображаемое имя предмета
const getItemDisplayName = (material: string) => {
return material
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (l) => l.toUpperCase());
};
return (
<Box>
<Box
sx={{
display: 'flex',
gap: '1vw',
alignItems: 'center',
mb: '2vw',
}}
>
<Typography variant="h5" color="white">
Ваш инвентарь
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{loading ? (
<FullScreenLoader fullScreen={false} message="Загрузка инвентаря..." />
) : (
<>
{inventoryItems.length === 0 ? (
<Typography
variant="body1"
color="white"
sx={{ textAlign: 'center', my: 4 }}
>
Ваш инвентарь пуст или не удалось загрузить предметы.
</Typography>
) : (
<Grid
container
spacing={2}
columns={10}
sx={{ justifyContent: 'center' }}
>
{inventoryItems.map((item) =>
item.material !== 'AIR' && item.amount > 0 ? (
<Grid item xs={1} key={item.slot}>
<Card
sx={{
bgcolor: 'rgba(255, 255, 255, 0.05)',
cursor: 'pointer',
transition: 'transform 0.2s',
'&:hover': { transform: 'scale(1.03)' },
borderRadius: '1vw',
}}
onClick={() => handleOpenSellDialog(item)}
>
<CardMedia
component="img"
sx={{
minWidth: '10vw',
minHeight: '10vw',
maxHeight: '10vw',
objectFit: 'contain',
p: '1vw',
imageRendering: 'pixelated',
}}
image={`https://cdn.minecraft.popa-popa.ru/textures/${item.material.toLowerCase()}.png`}
alt={item.material}
/>
<CardContent sx={{ p: 1 }}>
<Box
sx={{
display: 'flex',
gap: '1vw',
justifyContent: 'space-between',
}}
>
<Typography
variant="body2"
color="white"
noWrap
sx={{ fontSize: '0.8vw' }}
>
{getItemDisplayName(item.material)}
</Typography>
<Typography
variant="body2"
color="white"
sx={{ fontSize: '0.8vw' }}
>
{item.amount > 1 ? `x${item.amount}` : ''}
</Typography>
</Box>
{Object.keys(item.enchants || {}).length > 0 && (
<Typography
variant="caption"
color="secondary"
sx={{ display: 'block', fontSize: '0.8vw' }}
>
Зачарования: {Object.keys(item.enchants).length}
</Typography>
)}
</CardContent>
</Card>
</Grid>
) : null,
)}
</Grid>
)}
</>
)}
{/* Диалог для продажи предмета */}
<Dialog
open={sellDialogOpen}
onClose={handleCloseSellDialog}
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: 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"
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',
}}
/>
<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>
{/* Поля */}
<TextField
label="Количество"
fullWidth
value={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="Цена (за всё)"
fullWidth
value={price}
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 && (
<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}
</Box>
)}
{/* Подсказка */}
<Typography sx={{ mt: 1.1, color: 'rgba(255,255,255,0.60)', fontWeight: 700 }}>
Цена указывается за весь лот!
</Typography>
</Box>
)}
</DialogContent>
<DialogActions sx={{ p: '1.2vw' }}>
<Button onClick={handleCloseSellDialog} sx={SECONDARY_BTN_SX}>
Отмена
</Button>
<Button
onClick={handleSellItem}
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 ? 'Выставляем…' : 'Продать'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
})
export default PlayerInventory;

View 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;

View 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>
);
}

View File

@ -0,0 +1,118 @@
import { Box, Typography, Avatar } from '@mui/material';
import { useEffect, useState } from 'react';
interface ServerStatusProps {
serverIp: string;
serverPort?: number;
refreshInterval?: number; // Интервал обновления в миллисекундах
}
const ServerStatus = ({
serverIp,
serverPort,
refreshInterval = 60000, // По умолчанию обновление раз в минуту
}: ServerStatusProps) => {
const [serverStatus, setServerStatus] = useState<{
online: number;
max: number;
loading: boolean;
error: string | null;
icon: string | null;
motd: string;
}>({
online: 0,
max: 0,
loading: true,
error: null,
icon: null,
motd: '',
});
useEffect(() => {
// Функция для получения статуса сервера
const fetchServerStatus = async () => {
try {
setServerStatus((prev) => ({ ...prev, loading: true, error: null }));
console.log('Отправляем запрос на сервер с параметрами:', {
host: serverIp,
port: serverPort || 25565,
});
// Проверяем, что serverIp имеет значение
if (!serverIp) {
throw new Error('Адрес сервера не указан');
}
const result = await window.electron.ipcRenderer.invoke(
'get-server-status',
{
host: serverIp,
port: serverPort || 25565,
},
);
if (result.success) {
setServerStatus({
online: result.online,
max: result.max,
loading: false,
error: null,
icon: result.icon,
motd: result.motd || serverIp,
});
} else {
setServerStatus({
online: 0,
max: 0,
loading: false,
error: result.error || 'Неизвестная ошибка',
icon: null,
motd: '',
});
}
} catch (error) {
console.error('Ошибка при получении статуса сервера:', error);
setServerStatus((prev) => ({
...prev,
loading: false,
error: 'Ошибка при получении статуса сервера',
icon: null,
}));
}
};
// Загрузка при первом рендере
fetchServerStatus();
// Периодическое обновление
const interval = setInterval(fetchServerStatus, refreshInterval);
return () => clearInterval(interval);
}, [serverIp, serverPort, refreshInterval]);
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Отображаем иконку сервера или иконку по умолчанию */}
{serverStatus.icon ? (
<Avatar
src={serverStatus.icon}
alt={serverStatus.motd || 'Minecraft сервер'}
sx={{ width: '2em', height: '2em' }}
/>
) : (
<Avatar sx={{ width: '2em', height: '2em', bgcolor: 'primary.main' }}>
?
</Avatar>
)}
{serverStatus.error ? (
<Typography color="error">Ошибка загрузки</Typography>
) : (
<Typography sx={{ fontWeight: 'bold' }}>
{serverStatus.online} / {serverStatus.max} игроков
</Typography>
)}
</Box>
);
};
export default ServerStatus;

View File

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

View 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>
);
}

View File

@ -0,0 +1,132 @@
import { useEffect, useRef } from 'react';
interface SkinViewerProps {
width?: number;
height?: number;
skinUrl?: string;
capeUrl?: string;
walkingSpeed?: number;
autoRotate?: boolean;
}
const DEFAULT_SKIN =
'https://static.planetminecraft.com/files/resource_media/skin/original-steve-15053860.png';
export default function SkinViewer({
width = 300,
height = 400,
skinUrl,
capeUrl,
walkingSpeed = 0.5,
autoRotate = true,
}: SkinViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewerRef = useRef<any>(null);
const animRef = useRef<any>(null);
// 1) Инициализируем viewer ОДИН РАЗ
useEffect(() => {
let disposed = false;
const init = async () => {
if (!canvasRef.current || viewerRef.current) return;
try {
const skinview3d = await import('skinview3d');
if (disposed) return;
const viewer = new skinview3d.SkinViewer({
canvas: canvasRef.current,
width,
height,
});
// базовая настройка
viewer.autoRotate = autoRotate;
// анимация ходьбы
const walking = new skinview3d.WalkingAnimation();
walking.speed = walkingSpeed;
viewer.animation = walking;
viewerRef.current = viewer;
animRef.current = walking;
// выставляем ресурсы сразу
const finalSkin = skinUrl?.trim() ? skinUrl : DEFAULT_SKIN;
await viewer.loadSkin(finalSkin);
if (capeUrl?.trim()) {
await viewer.loadCape(capeUrl);
} else {
viewer.cape = null;
}
} catch (e) {
console.error('Ошибка при инициализации skinview3d:', e);
}
};
init();
return () => {
disposed = true;
if (viewerRef.current) {
viewerRef.current.dispose();
viewerRef.current = null;
animRef.current = null;
}
};
// ⚠️ пустой deps — создаём один раз
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 2) Обновляем размеры (не пересоздаём viewer)
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
viewer.width = width;
viewer.height = height;
}, [width, height]);
// 3) Обновляем автоповорот
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
viewer.autoRotate = autoRotate;
}, [autoRotate]);
// 4) Обновляем скорость анимации
useEffect(() => {
const walking = animRef.current;
if (!walking) return;
walking.speed = walkingSpeed;
}, [walkingSpeed]);
// 5) Обновляем скин
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
const finalSkin = skinUrl?.trim() ? skinUrl : DEFAULT_SKIN;
// защита от кеша: добавим “bust” только если URL уже имеет query — не обязательно, но помогает
const url = finalSkin.includes('?') ? `${finalSkin}&t=${Date.now()}` : `${finalSkin}?t=${Date.now()}`;
viewer.loadSkin(url).catch((e: any) => console.error('loadSkin error:', e));
}, [skinUrl]);
// 6) Обновляем плащ
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
if (capeUrl?.trim()) {
const url = capeUrl.includes('?') ? `${capeUrl}&t=${Date.now()}` : `${capeUrl}?t=${Date.now()}`;
viewer.loadCape(url).catch((e: any) => console.error('loadCape error:', e));
} else {
viewer.cape = null;
}
}, [capeUrl]);
return <canvas ref={canvasRef} width={width} height={height} style={{ display: 'block' }} />;
}

View File

@ -0,0 +1,924 @@
import {
Box,
Button,
Tab,
Tabs,
Typography,
Menu,
MenuItem,
Divider,
} from '@mui/material';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import { useLocation, useNavigate } from 'react-router-dom';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useEffect, useRef, useState, useCallback } from 'react';
import CustomTooltip from './Notifications/CustomTooltip';
import CoinsDisplay from './CoinsDisplay';
import { HeadAvatar } from './HeadAvatar';
import { fetchPlayer } from './../api';
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import PersonIcon from '@mui/icons-material/Person';
import SettingsIcon from '@mui/icons-material/Settings';
import { useTheme } from '@mui/material/styles';
import InventoryIcon from '@mui/icons-material/Inventory';
import { RiCoupon3Fill } from 'react-icons/ri';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
import {
isNotificationsEnabled,
getNotifPositionFromSettings,
} from '../utils/notifications';
declare global {
interface Window {
electron: {
ipcRenderer: {
invoke(channel: string, ...args: unknown[]): Promise<any>;
on(channel: string, func: (...args: unknown[]) => void): void;
removeAllListeners(channel: string): void;
};
};
}
}
// Определяем пропсы
interface TopBarProps {
onRegister?: () => void; // Опционально, если нужен обработчик регистрации
username?: string;
}
export default function TopBar({ onRegister, username }: TopBarProps) {
// Получаем текущий путь
const location = useLocation();
const isLoginPage = location.pathname === '/login';
const [isAuthed, setIsAuthed] = useState(false);
const isLaunchPage = location.pathname.startsWith('/launch');
const isRegistrationPage = location.pathname === '/registration';
const navigate = useNavigate();
const tabsWrapperRef = useRef<HTMLDivElement | null>(null);
const tabsRootRef = useRef<HTMLDivElement | null>(null);
const theme = useTheme();
const updateGradientVars = useCallback(() => {
const root = tabsRootRef.current;
if (!root) return;
const tabsRect = root.getBoundingClientRect();
const active = root.querySelector<HTMLElement>('.MuiTab-root.Mui-selected');
if (!active) return;
const activeRect = active.getBoundingClientRect();
const x = activeRect.left - tabsRect.left;
root.style.setProperty('--tabs-w', `${tabsRect.width}px`);
root.style.setProperty('--active-x', `${x}px`);
}, []);
const [skinUrl, setSkinUrl] = useState<string>('');
const [skinVersion, setSkinVersion] = useState(0);
const [avatarAnchorEl, setAvatarAnchorEl] = useState<null | HTMLElement>(
null,
);
// ===== QUICK LAUNCH ===== \\
const [lastVersion, setLastVersion] = useState<null | any>(null);
useEffect(() => {
try {
const raw = localStorage.getItem('last_launched_version');
if (!raw) return;
setLastVersion(JSON.parse(raw));
} catch {
setLastVersion(null);
}
}, []);
// ===== QUICK LAUNCH ===== \\
const path = location.pathname || '';
const isAuthPage =
path.startsWith('/login') || path.startsWith('/registration');
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);
};
const TAB_ROUTES: Array<{
value: number;
match: (p: string) => boolean;
to: string;
}> = [
{
value: 0,
match: (p) => p === '/news',
to: '/news',
},
{
value: 1,
match: (p) => p === '/',
to: '/',
},
{
value: 2,
match: (p) => p.startsWith('/shop'),
to: '/shop',
},
{
value: 3,
match: (p) => p.startsWith('/marketplace'),
to: '/marketplace',
},
{
value: 4,
match: (p) => p.startsWith('/voice'),
to: '/voice',
},
];
const selectedTab =
TAB_ROUTES.find((r) => r.match(location.pathname))?.value ?? false;
useEffect(() => {
updateGradientVars();
window.addEventListener('resize', updateGradientVars);
return () => window.removeEventListener('resize', updateGradientVars);
}, [updateGradientVars, selectedTab, location.pathname]);
useEffect(() => {
const saved = localStorage.getItem('launcher_config');
try {
const cfg = saved ? JSON.parse(saved) : null;
setIsAuthed(Boolean(cfg?.accessToken)); // или cfg?.uuid/username — как у тебя принято
} catch {
setIsAuthed(false);
}
}, [location.pathname]); // можно и без dependency, но так надёжнее при logout/login
const avatarMenuOpen = Boolean(avatarAnchorEl);
const handleAvatarClick = (event: React.MouseEvent<HTMLElement>) => {
setAvatarAnchorEl(event.currentTarget);
};
const handleAvatarMenuClose = () => {
setAvatarAnchorEl(null);
};
// useEffect(() => {
// if (location.pathname === '/news') {
// setValue(0);
// setActivePage('news');
// } else if (location.pathname === '/') {
// setValue(1);
// setActivePage('versions');
// } else if (location.pathname.startsWith('/shop')) {
// setValue(3);
// setActivePage('shop');
// } else if (location.pathname.startsWith('/marketplace')) {
// setValue(4);
// setActivePage('marketplace');
// } else {
// // любые страницы не из TopBar: /profile, /daily, /dailyquests, и т.д.
// setValue(false);
// setActivePage('');
// }
// }, [location.pathname]);
const handleLaunchPage = () => {
navigate('/');
};
const handleTabsWheel = (event: React.WheelEvent<HTMLDivElement>) => {
// чтобы страница не скроллилась вертикально
event.preventDefault();
if (!tabsWrapperRef.current) return;
// Находим внутренний скроллер MUI Tabs
const scroller = tabsWrapperRef.current.querySelector(
'.MuiTabs-scroller',
) as HTMLDivElement | null;
if (!scroller) return;
// Прокручиваем горизонтально, используя вертикальный скролл мыши
scroller.scrollLeft += event.deltaY * 0.3;
requestAnimationFrame(updateGradientVars);
};
// const getPageTitle = () => {
// if (isLoginPage) {
// return 'Вход';
// }
// if (isLaunchPage) {
// return 'Запуск';
// }
// if (isVersionsExplorerPage) {
// if (activePage === 'versions') {
// return 'Версии';
// }
// if (activePage === 'profile') {
// return 'Профиль';
// }
// if (activePage === 'shop') {
// return 'Магазин';
// }
// if (activePage === 'marketplace') {
// return 'Рынок';
// }
// }
// return 'Неизвестная страница';
// };
// Функция для получения количества монет
const tabBaseSx = [{ fontSize: '0.7em' }, theme.launcher.topbar.tabBase];
const logout = () => {
localStorage.removeItem('launcher_config');
localStorage.removeItem(`coins:${username}`);
localStorage.removeItem('last_route');
navigate('/login');
window.electron.ipcRenderer.invoke('auth-changed', { isAuthed: false });
};
const loadSkin = useCallback(async () => {
if (!isAuthed) {
setSkinUrl('');
return;
}
const savedConfig = localStorage.getItem('launcher_config');
if (!savedConfig) return;
let cfg: any = null;
try {
cfg = JSON.parse(savedConfig);
} catch {
return;
}
const uuid = cfg?.uuid;
if (!uuid) return;
try {
const player = await fetchPlayer(uuid);
setSkinUrl(player.skin_url || '');
} catch (e) {
console.error('Не удалось получить скин:', e);
setSkinUrl('');
}
}, [isAuthed]);
useEffect(() => {
loadSkin();
}, [loadSkin, location.pathname]);
useEffect(() => {
const handler = () => {
setSkinVersion((v) => v + 1);
loadSkin();
};
window.addEventListener('skin-updated', handler as EventListener);
return () =>
window.removeEventListener('skin-updated', handler as EventListener);
}, [loadSkin]);
useEffect(() => {
const handler = () => {
requestAnimationFrame(updateGradientVars);
};
window.addEventListener('settings-updated', handler as EventListener);
return () =>
window.removeEventListener('settings-updated', handler as EventListener);
}, [updateGradientVars]);
const handleQuickLaunch = async () => {
const raw = localStorage.getItem('last_launched_version');
if (!raw) {
showNotification('Вы не запускали ни одну из сборок!', 'warning');
return;
}
const ctx = JSON.parse(raw);
const savedConfig = JSON.parse(
localStorage.getItem('launcher_config') || '{}',
);
if (!savedConfig.accessToken) {
showNotification('Вы не авторизованы', 'error');
return;
}
await window.electron.ipcRenderer.invoke('launch-minecraft', {
accessToken: savedConfig.accessToken,
uuid: savedConfig.uuid,
username: savedConfig.username,
memory: ctx.memory,
baseVersion: ctx.baseVersion,
fabricVersion: ctx.fabricVersion,
packName: ctx.packName,
serverIp: ctx.serverIp,
isVanillaVersion: ctx.isVanillaVersion,
versionToLaunchOverride: ctx.versionToLaunchOverride,
});
};
const getLastLaunchLabel = (v: any) => {
if (!v) return '';
const title = v.isVanillaVersion
? `Minecraft ${v.versionId}`
: `Сборка ${v.packName}`;
const details = [
v.baseVersion ? `MC ${v.baseVersion}` : null,
v.memory ? `${v.memory} MB RAM` : null,
]
.filter(Boolean)
.join(' · ');
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '0.2vw' }}>
<Typography sx={{ fontSize: '0.9vw', fontWeight: 600 }}>
{title}
</Typography>
<Typography sx={{ fontSize: '0.75vw', opacity: 0.7 }}>
{details}
</Typography>
</Box>
);
};
return (
<Box
className={isAuthPage ? undefined : 'glass-ui'}
sx={[
{
display: 'flex',
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: '8vh',
zIndex: 1000,
width: '100%',
WebkitAppRegion: 'drag',
overflow: 'hidden',
justifyContent: 'space-between',
alignItems: 'center',
},
theme.launcher.topbar.firstBox,
]}
>
{/* Левая часть */}
<Box
sx={{
display: 'flex',
WebkitAppRegion: 'no-drag',
gap: '2vw',
alignItems: 'center',
marginLeft: '1vw',
}}
>
{(isLaunchPage || isRegistrationPage) && (
<Button
variant="outlined"
onClick={() => handleLaunchPage()}
sx={[
{
width: '3em',
height: '3em',
borderRadius: '50%',
minWidth: 'unset',
minHeight: 'unset',
},
theme.launcher.topbar.backButton,
]}
>
<ArrowBackRoundedIcon />
</Button>
)}
{isAuthed && !isLaunchPage && (
<Box
ref={tabsWrapperRef}
onWheel={handleTabsWheel}
// старый вариант
sx={{
borderBottom: 1,
...theme.launcher.topbar.tabsBox,
// '& .MuiTabs-indicator': {
// backgroundColor: 'rgba(255, 77, 77, 1)',
// },
}}
// sx={{
// borderBottom: 'none',
// borderRadius: '2vw',
// px: '0.6vw',
// py: '0.4vw',
// background: 'rgba(0,0,0,0.35)',
// border: '1px solid rgba(255,255,255,0.08)',
// boxShadow: '0 8px 20px rgba(0,0,0,0.25)',
// '& .MuiTabs-indicator': {
// height: '100%',
// borderRadius: '1.6vw',
// background:
// 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
// opacity: 0.18,
// },
// }}
>
<CustomTooltip
title={
'Покрути колесиком мыши чтобы увидеть остальные элементы меню'
}
arrow
placement="bottom"
TransitionProps={{ timeout: 100 }}
>
<Tabs
ref={tabsRootRef}
value={selectedTab}
onChange={(_, newValue) => {
const route = TAB_ROUTES.find((r) => r.value === newValue);
if (route) navigate(route.to);
}}
aria-label="basic tabs example"
variant="scrollable"
scrollButtons={false}
disableRipple={true}
sx={{
...theme.launcher.topbar.tabs,
}}
>
<Tab
label="Новости"
disableRipple={true}
sx={[
...tabBaseSx,
selectedTab === 0 ? theme.launcher.topbar.tabActive : null,
]}
/>
<Tab
label="Версии"
disableRipple={true}
sx={[
...tabBaseSx,
selectedTab === 1 ? theme.launcher.topbar.tabActive : null,
]}
/>
<Tab
label="Магазин"
disableRipple={true}
sx={[
...tabBaseSx,
selectedTab === 2 ? theme.launcher.topbar.tabActive : null,
]}
/>
<Tab
label="Рынок"
disableRipple={true}
sx={[
...tabBaseSx,
selectedTab === 3 ? theme.launcher.topbar.tabActive : null,
]}
/>
</Tabs>
</CustomTooltip>
</Box>
)}
</Box>
{/* Центр */}
<Box
sx={{
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexGrow: 1,
WebkitAppRegion: 'drag',
}}
>
{/* <Typography
variant="h6"
sx={{ color: 'white', fontFamily: 'Benzin-Bold' }}
>
{getPageTitle()}
</Typography> */}
</Box>
{/* Правая часть со всеми кнопками */}
<Box
sx={{
display: 'flex',
WebkitAppRegion: 'no-drag',
gap: '1vw',
alignItems: 'center',
marginRight: '1vw',
}}
>
{lastVersion && (
<CustomTooltip
title={getLastLaunchLabel(lastVersion)}
arrow
placement="bottom"
essential
TransitionProps={{ timeout: 120 }}
>
<Button
onClick={handleQuickLaunch}
disableRipple
disableFocusRipple
sx={{
minWidth: 'unset',
width: '3vw',
height: '3vw',
borderRadius: '3vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
overflow: 'hidden',
px: '0.8vw',
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)',
color: 'white',
backdropFilter: 'blur(14px)',
transition: 'all 0.3s ease',
'& .quick-text': {
opacity: 0,
whiteSpace: 'nowrap',
marginRight: '0.6vw',
fontSize: '0.9vw',
fontFamily: 'Benzin-Bold',
transform: 'translateX(10px)',
transition: 'all 0.25s ease',
},
'&:hover': {
width: '16.5vw',
transform: 'scale(1.05)',
'& .quick-text': {
opacity: 1,
transform: 'translateX(0)',
},
},
'&:after': {
content: '""',
position: 'absolute',
left: '0%',
right: '0%',
bottom: 0,
height: '0.15vw',
borderRadius: '999px',
// background:
// 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
opacity: 0.9,
},
}}
>
<span className="quick-text">Быстрый запуск</span>
<span style={{ fontSize: '1vw' }}></span>
</Button>
</CustomTooltip>
)}
<Button
onClick={() => navigate('/voice')}
disableRipple
disableFocusRipple
sx={{
minWidth: 'unset',
width: '3vw',
height: '3vw',
borderRadius: '3vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
overflow: 'hidden',
px: '0.8vw',
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)',
color: 'white',
backdropFilter: 'blur(14px)',
transition: 'all 0.3s ease',
'& .quick-text': {
opacity: 0,
whiteSpace: 'nowrap',
marginRight: '0.6vw',
fontSize: '0.9vw',
fontFamily: 'Benzin-Bold',
transform: 'translateX(10px)',
transition: 'all 0.25s ease',
},
'&:hover': {
width: '16.5vw',
transform: 'scale(1.05)',
'& .quick-text': {
opacity: 1,
transform: 'translateX(0)',
},
},
'&:after': {
content: '""',
position: 'absolute',
left: '0%',
right: '0%',
bottom: 0,
height: '0.15vw',
borderRadius: '999px',
// background:
// 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
opacity: 0.9,
},
}}
>
<span className="quick-text">Голосовой чат</span>
<span style={{ fontSize: '1vw' }}>🎙</span>
</Button>
{!isLoginPage && !isRegistrationPage && username && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: '1vw' }}>
<HeadAvatar
skinUrl={skinUrl}
size={44}
version={skinVersion}
style={{
borderRadius: '3vw',
cursor: 'pointer',
}}
onClick={handleAvatarClick}
/>
</Box>
)}
{/* Кнопка регистрации, если на странице логина */}
{!isLoginPage && !isRegistrationPage && username && (
<CoinsDisplay
username={username}
size="medium"
autoUpdate={true}
showTooltip={true}
onClick={() => navigate('/fakepaymentpage')}
disableRefreshOnClick={true} // чтобы клик не дёргал fetchCoins
/>
)}
{/* Кнопки управления окном */}
<Button
onClick={() => {
window.electron.ipcRenderer.invoke('minimize-app');
}}
sx={{
minWidth: 'unset',
minHeight: 'unset',
width: '3em',
height: '3em',
borderRadius: '50%',
...theme.launcher.topbar.windowControlButton,
}}
>
<svg
className="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium"
focusable="false"
aria-hidden="true"
viewBox="0 0 24 24"
>
<path
d="M 7 19 h 10 c 0.55 0 1 0.45 1 1 s -0.45 1 -1 1 H 7 c -0.55 0 -1 -0.45 -1 -1 s 0.45 -1 1 -1"
fill={theme.launcher.topbar.windowControlIcon.color}
></path>
</svg>
</Button>
<Button
onClick={() => {
window.electron.ipcRenderer.invoke('close-app');
}}
sx={{
minWidth: 'unset',
minHeight: 'unset',
width: '3em',
height: '3em',
borderRadius: '50%',
...theme.launcher.topbar.windowControlButton,
}}
>
<CloseRoundedIcon
sx={{ color: theme.launcher.topbar.windowControlIcon.color }}
/>
</Button>
</Box>
<Menu
anchorEl={avatarAnchorEl}
open={avatarMenuOpen}
onClose={handleAvatarMenuClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
sx: {
mt: '0.5vw',
borderRadius: '1vw',
minWidth: '16vw',
...theme.launcher.topbar.menuPaper,
},
}}
>
{/* ===== 1 строка: аватар + ник + валюта ===== */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: '1.5vw',
alignItems: 'center',
px: '2vw',
py: '0.8vw',
}}
>
<HeadAvatar
skinUrl={skinUrl}
size={40}
version={skinVersion}
style={{ borderRadius: '3vw' }}
/>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography
sx={[{ fontSize: '2vw' }, theme.launcher.topbar.menuUsername]}
>
{username || 'Игрок'}
</Typography>
<CoinsDisplay
username={username}
size="medium"
autoUpdate={true}
showTooltip={false}
sx={{
border: 'none',
padding: '0vw',
}}
backgroundColor={'rgba(0, 0, 0, 0)'}
/>
</Box>
</Box>
<Divider sx={{ my: '0.4vw', ...theme.launcher.topbar.menuDivider }} />
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/profile');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<PersonIcon sx={{ fontSize: '2vw' }} /> Профиль
</MenuItem>
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/inventory');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<InventoryIcon sx={{ fontSize: '2vw' }} /> Инвентарь
</MenuItem>
{/* ===== 2 строка: ежедневные задания ===== */}
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/dailyquests');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<CalendarMonthIcon sx={{ fontSize: '2vw' }} /> Ежедневные задания
</MenuItem>
{/* ===== 3 строка: ежедневная награда ===== */}
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/daily');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<EmojiEventsIcon sx={{ fontSize: '2vw' }} /> Ежедневная награда
</MenuItem>
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/settings');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<SettingsIcon sx={{ fontSize: '2vw' }} /> Настройки
</MenuItem>
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/promocode');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<RiCoupon3Fill style={{ fontSize: '2vw' }} /> Промокоды
</MenuItem>
<Divider sx={{ my: '0.4vw', ...theme.launcher.topbar.menuDivider }} />
{!isLoginPage && !isRegistrationPage && username && (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Button
variant="outlined"
color="primary"
onClick={() => {
handleAvatarMenuClose();
logout();
}}
sx={[
{
width: '90%',
height: '3vw',
fontSize: '1.2vw',
mx: '1vw',
},
theme.launcher.topbar.logoutButton,
]}
>
Выйти
</Button>
</Box>
)}
{/* ↓↓↓ дальше ты сам добавишь пункты ↓↓↓ */}
</Menu>
</Box>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,23 @@
import { Typography } from '@mui/material';
import { Box } from '@mui/material';
export default function PopaPopa() {
return (
<Box sx={{ display: 'flex' }}>
<Typography variant="h3">POPA</Typography>
<Typography variant="h3">-</Typography>
<Typography
variant="h3"
sx={{
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',
}}
>
POPA
</Typography>
</Box>
);
}

View File

@ -0,0 +1,102 @@
import { useState } from 'react';
import {
authenticate,
validateToken,
refreshToken,
type AuthSession,
} from '../api';
export default function useAuth() {
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');
// Получаем 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 isValid;
} catch (error) {
console.error('Ошибка при валидации токена:', error);
setStatus('error');
return false;
}
};
// Обновление токена (HTTP напрямую)
const refreshSession = async (
accessToken: string,
clientToken: string,
): Promise<AuthSession | null> => {
try {
setStatus('refreshing');
// Прямой HTTP-запрос на обновление
const session = await refreshToken(accessToken, clientToken);
setStatus('idle');
return session;
} catch (error) {
console.error('Ошибка при обновлении токена:', error);
setStatus('error');
throw error;
}
};
return {
status,
authenticateUser,
validateSession,
refreshSession,
applySession,
};
}

View File

@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
// Добавляем определение типа Config
interface Config {
username: string;
password: string;
memory: number;
comfortVersion: string;
accessToken: string;
clientToken: string;
uuid?: string; // Добавляем uuid, который используется для авторизации
}
const useConfig = () => {
const [config, setConfig] = useState<Config>({
username: '',
password: '',
memory: 4096,
comfortVersion: '',
accessToken: '',
clientToken: '',
});
const loadInitialConfig = () => {
try {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
return JSON.parse(savedConfig);
}
} catch (error) {
console.log('Ошибка загрузки конфигурации:', error);
}
return {
username: '',
password: '',
comfortVersion: '',
accessToken: '',
clientToken: '',
};
};
useEffect(() => {
const savedConfig = loadInitialConfig();
setConfig(savedConfig);
}, []);
const saveConfig = (newConfig: Partial<Config>) => {
const updatedConfig = { ...config, ...newConfig };
setConfig(updatedConfig);
localStorage.setItem('launcher_config', JSON.stringify(updatedConfig));
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setConfig((prev) => ({ ...prev, [name]: value }));
};
return { config, setConfig, saveConfig, handleInputChange };
};
export default useConfig;

View File

@ -6,7 +6,7 @@
http-equiv="Content-Security-Policy"
content="script-src 'self' 'unsafe-inline'"
/>
<title>Hello Electron React!</title>
<title>popa-launcher</title>
</head>
<body>
<div id="root"></div>

View File

@ -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) => {

View 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
};
}

View 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>
);
}

View 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>
);
}

View 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.63.2
const stepsCount = 3 + Math.floor(Math.random() * 4); // 36
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>
);
}

View 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>
);
}

View File

@ -0,0 +1,670 @@
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';
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 {
electron: {
ipcRenderer: {
invoke(channel: string, ...args: unknown[]): Promise<any>;
on(channel: string, func: (...args: unknown[]) => void): void;
removeAllListeners(channel: string): void;
};
};
}
}
// Определяем тип для props
interface LaunchPageProps {
onLaunchPage?: () => void;
launchOptions?: {
downloadUrl: string;
apiReleaseUrl: string;
versionFileName: string;
packName: string;
memory: number;
baseVersion: string;
serverIp: string;
fabricVersion: string;
neoForgeVersion?: string;
loaderType?: string;
};
}
const LaunchPage = ({
onLaunchPage,
launchOptions = {} as any,
}: LaunchPageProps) => {
const navigate = useNavigate();
const { versionId } = useParams();
const [versionConfig, setVersionConfig] = useState<any>(null);
const [fullVersionConfig, setFullVersionConfig] = useState<any>(null); // Полная конфигурация из Gist
// Начальное состояние должно быть пустым или с минимальными значениями
const [config, setConfig] = useState<{
memory: number;
preserveFiles: string[];
}>({
memory: 0,
preserveFiles: [],
});
const [isDownloading, setIsDownloading] = useState(false);
const [progress, setProgress] = useState(0);
const [buffer, setBuffer] = useState(10);
const [installStatus, setInstallStatus] = useState('');
const [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);
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (!savedConfig || !JSON.parse(savedConfig).accessToken) {
navigate('/login');
}
const overallProgressListener = (...args: unknown[]) => {
const value = args[0] as number; // 0..100
setProgress(value);
setBuffer(Math.min(value + 10, 100));
};
const statusListener = (...args: unknown[]) => {
const status = args[0] as { step: string; message: string };
setInstallStep(status.step);
setInstallMessage(status.message);
};
const minecraftErrorListener = (...args: unknown[]) => {
const payload = (args[0] || {}) as {
message?: string;
stderr?: string;
code?: number;
};
// Главное — показать пользователю, что запуск не удался
showNotification(
payload.message ||
'Minecraft завершился с ошибкой. Подробности смотрите в логах.',
'error',
);
};
const minecraftStartedListener = () => {
setIsGameRunning(true);
const 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('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;
try {
// Сначала проверяем, есть ли конфигурация в localStorage
const savedConfig = localStorage.getItem('selected_version_config');
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
// Если конфиг пустой — считаем, что он невалидный и идём по IPC-ветке
if (Object.keys(parsedConfig).length > 0) {
setVersionConfig(parsedConfig);
setFullVersionConfig(parsedConfig);
setConfig({
memory: parsedConfig.memory || 4096,
preserveFiles: parsedConfig.preserveFiles || [],
});
localStorage.removeItem('selected_version_config');
return;
} else {
localStorage.removeItem('selected_version_config');
}
}
// Загружаем полную конфигурацию из Gist
const fullConfig = await fetchFullVersionConfig();
if (fullConfig) {
setFullVersionConfig(fullConfig);
// Сохраняем только config часть для совместимости
setVersionConfig(fullConfig.config || {});
setConfig({
memory: fullConfig.config?.memory || 4096,
preserveFiles: fullConfig.config?.preserveFiles || [],
});
} else {
// Если не удалось получить конфигурацию из 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);
// Используем значения по умолчанию
const defaultConfig = {
downloadUrl: '',
apiReleaseUrl: '',
versionFileName: `${versionId}_version.txt`,
packName: versionId || 'Comfort',
memory: 4096,
baseVersion: '1.21.4',
serverIp: 'popa-popa.ru',
fabricVersion: '0.16.14',
preserveFiles: ['popa-launcher-config.json'],
};
setVersionConfig(defaultConfig);
setConfig({
memory: defaultConfig.memory,
preserveFiles: defaultConfig.preserveFiles || [],
});
}
};
fetchVersionConfig();
}, [versionId]);
const showNotification = (
message: React.ReactNode,
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
position: NotificationPosition = getNotifPositionFromSettings(),
) => {
if (!isNotificationsEnabled()) return;
setNotifMsg(message);
setNotifSeverity(severity);
setNotifPos(position);
setNotifOpen(true);
};
// Функция для запуска игры с настройками выбранной версии
const handleLaunchMinecraft = async () => {
try {
setIsDownloading(true);
setBuffer(10);
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 =
!currentConfig.downloadUrl || currentConfig.downloadUrl === '';
if (!isVanillaVersion) {
// Если это не ванильная версия, выполняем загрузку и распаковку
const packOptions = {
downloadUrl: currentConfig.downloadUrl,
apiReleaseUrl: currentConfig.apiReleaseUrl,
versionFileName: currentConfig.versionFileName,
packName: versionId || currentConfig.packName,
preserveFiles: config.preserveFiles,
};
// Передаем опции для скачивания
const downloadResult = await window.electron.ipcRenderer.invoke(
'download-and-extract',
packOptions,
);
if (downloadResult?.success) {
if (downloadResult.updated) {
showNotification(
`Сборка ${downloadResult.packName} успешно обновлена до версии ${downloadResult.version}`,
'success',
);
} else {
showNotification(
`Установлена актуальная версия сборки ${downloadResult.packName} (${downloadResult.version})`,
'info',
);
}
}
} else {
showNotification('Запускаем ванильный Minecraft...', 'info');
}
// Опции для запуска Minecraft
const savedConfig = JSON.parse(
localStorage.getItem('launcher_config') || '{}',
);
// Формируем полные опции для запуска
const options: any = {
accessToken: savedConfig.accessToken,
uuid: savedConfig.uuid,
username: savedConfig.username,
memory: config.memory,
baseVersion: currentConfig.baseVersion,
packName: versionId || currentConfig.packName,
serverIp: currentConfig.serverIp,
fabricVersion: currentConfig.fabricVersion,
neoForgeVersion: currentConfig.neoForgeVersion,
loaderType: currentConfig.loaderType || 'fabric',
isVanillaVersion: isVanillaVersion,
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,
);
if (launchResult?.success) {
showNotification('Minecraft успешно запущен!', 'success');
} else if (launchResult?.error) {
showNotification(`Ошибка запуска: ${launchResult.error}`, 'error');
}
} catch (error: any) {
console.error('Error:', error);
showNotification(`Ошибка: ${error.message}`, 'error');
} finally {
setIsDownloading(false);
}
};
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 {
const configToSave = {
memory: config.memory,
preserveFiles: config.preserveFiles || [],
};
await window.electron.ipcRenderer.invoke('save-pack-config', {
packName: versionId || versionConfig?.packName || 'Comfort',
config: configToSave,
});
showNotification('Настройки сохранены', 'success');
} catch (error) {
console.error('Ошибка при сохранении настроек:', error);
showNotification('Ошибка сохранения настроек', 'error');
}
};
return (
<Box sx={{ gap: '1vh', display: 'flex', flexDirection: 'column' }}>
<PopaPopa />
<Typography variant="h4">Игровой сервер</Typography>
<Typography variant="h4">долбаёбов в Minecraft</Typography>
<Box>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
СЕРВЕР ГДЕ ВСЕМ НА ВАС ПОХУЙ
</Typography>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
СЕРВЕР ГДЕ РАЗРЕШЕНЫ ОДНОПОЛЫЕ БРАКИ
</Typography>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
СЕРВЕР ГДЕ ВСЕ ДОЛБАЕБЫ
</Typography>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
СЕРВЕР ГДЕ НА СПАВНЕ БУДЕТ ХУЙ (ВОЗМОЖНО)
</Typography>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
СЕРВЕР ЗА КОТОРЫЙ ВЫ ПРОДАДИТЕ МАТЬ
</Typography>
<Typography variant="body1" sx={{ color: '#FFFFFF61' }}>
ТЫ МОЖЕШЬ КУПИТЬ АДМИНКУ И ПОЛУЧИТЬ ПИЗДЫ
</Typography>
</Box>
<Box>
<ServerStatus
serverIp={versionConfig?.serverIp || 'popa-popa.ru'}
refreshInterval={30000}
/>
</Box>
{isDownloading ? (
<Box sx={{ mb: 3, width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress
variant="buffer"
value={progress}
valueBuffer={buffer}
sx={{
height: '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(progress)}%`}</Typography>
</Box>
</Box>
</Box>
) : (
<Box
sx={{
display: 'flex',
gap: '1vw',
width: '100%', // родитель занимает всю ширину
}}
>
{/* Первая кнопка — растягивается на всё доступное пространство */}
<Button
variant="contained"
color="primary"
onClick={
isGameRunning ? handleStopMinecraft : handleLaunchMinecraft
}
sx={{
flexGrow: 1,
width: 'auto',
borderRadius: '3vw',
fontFamily: 'Benzin-Bold',
transition: 'transform 0.3s ease',
...(isGameRunning
? {
// 🔹 Стиль, когда игра запущена (серая кнопка)
background: 'linear-gradient(71deg, #555 0%, #777 100%)',
'&:hover': {
background: 'linear-gradient(71deg, #666 0%, #888 100%)',
transform: 'scale(1.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',
}),
}}
>
{isGameRunning ? 'Остановить Minecraft' : 'Запустить Minecraft'}
</Button>
{/* Вторая кнопка — квадратная, фиксированного размера (ширина = высоте) */}
<Button
variant="contained"
sx={{
flexShrink: 0, // не сжимается
aspectRatio: '1', // ширина = высоте
backgroundColor: 'grey',
borderRadius: '3vw',
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}
>
<SettingsIcon />
</Button>
</Box>
)}
<CustomNotification
open={notifOpen}
message={notifMsg}
severity={notifSeverity}
position={notifPos}
onClose={() => setNotifOpen(false)}
autoHideDuration={2500}
/>
<SettingsModal
open={open}
onClose={handleClose}
config={config}
onConfigChange={setConfig}
packName={versionId || versionConfig?.packName || 'Comfort'}
onSave={savePackConfig}
/>
</Box>
);
};
export default LaunchPage;

View File

@ -0,0 +1,890 @@
import {
Box,
Button,
Typography,
Paper,
Stack,
Divider,
ToggleButton,
ToggleButtonGroup,
IconButton,
} from '@mui/material';
import useAuth from '../hooks/useAuth';
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';
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, saveConfig, handleInputChange } = useConfig();
const auth = useAuth();
const [loading, setLoading] = useState(false);
// ===== 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()) {
showNotification('Введите никнейм!', 'warning');
return;
}
if (!config.password) {
showNotification('Введите пароль!', 'warning');
return;
}
setLoading(true);
try {
if (config.accessToken && config.clientToken) {
const isValid = await auth.validateSession(config.accessToken);
if (!isValid) {
const refreshed = await auth.refreshSession(
config.accessToken,
config.clientToken,
);
if (!refreshed) {
await auth.authenticateUser(
config.username,
config.password,
saveConfig,
);
}
}
} else {
await auth.authenticateUser(
config.username,
config.password,
saveConfig,
);
}
if (onLoginSuccess) onLoginSuccess(config.username);
showNotification('Успешный вход', 'success');
navigate('/');
} 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
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>
);
};
export default Login;

File diff suppressed because it is too large Load Diff

806
src/renderer/pages/News.tsx Normal file
View 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>
);
};

View File

@ -0,0 +1,620 @@
import { useEffect, useRef, useState } from 'react';
import SkinViewer from '../components/SkinViewer';
import {
fetchPlayer,
uploadSkin,
fetchCapes,
Cape,
activateCape,
deactivateCape,
} from '../api';
import {
Box,
Typography,
Paper,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
Alert,
} from '@mui/material';
import CapeCard from '../components/CapeCard';
import { FullScreenLoader } from '../components/FullScreenLoader';
import { OnlinePlayersPanel } from '../components/OnlinePlayersPanel';
import DailyRewards from '../components/Profile/DailyRewards';
import CustomNotification from '../components/Notifications/CustomNotification';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
import { useNavigate } from 'react-router-dom';
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
export default function Profile() {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [skin, setSkin] = useState<string>('');
const [cape, setCape] = useState<string>('');
const [username, setUsername] = useState<string>('');
const [skinFile, setSkinFile] = useState<File | null>(null);
const [skinModel, setSkinModel] = useState<string>(''); // slim или classic
const [uploadStatus, setUploadStatus] = useState<
'idle' | 'loading' | 'success' | 'error'
>('idle');
const [statusMessage, setStatusMessage] = useState<string>('');
const [isDragOver, setIsDragOver] = useState<boolean>(false);
const [capes, setCapes] = useState<Cape[]>([]);
const [uuid, setUuid] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [viewerWidth, setViewerWidth] = useState(500);
const [viewerHeight, setViewerHeight] = useState(600);
// notification
const [notifOpen, setNotifOpen] = useState(false);
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
const [notifSeverity, setNotifSeverity] = useState<
'success' | 'info' | 'warning' | 'error'
>('success');
const [notifPos, setNotifPos] = useState<NotificationPosition>({
vertical: 'top',
horizontal: 'right',
});
const [autoRotate, setAutoRotate] = useState(true);
const [walkingSpeed, setWalkingSpeed] = useState(0.5);
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
const config = JSON.parse(savedConfig);
if (config.uuid) {
loadPlayerData(config.uuid);
setUsername(config.username || '');
loadCapesData(config.username || '');
setUuid(config.uuid || '');
}
}
}, []);
useEffect(() => {
// Функция для обновления размеров
const updateDimensions = () => {
setViewerWidth(window.innerWidth * 0.4); // 25vw
setViewerHeight(window.innerWidth * 0.5); // 30vw
};
// Вызываем один раз при монтировании
updateDimensions();
// Добавляем слушатель изменения размера окна
window.addEventListener('resize', updateDimensions);
// Очистка при размонтировании
return () => {
window.removeEventListener('resize', updateDimensions);
};
}, []);
const loadPlayerData = async (uuid: string) => {
try {
setLoading(true);
const player = await fetchPlayer(uuid);
setSkin(player.skin_url);
setCape(player.cloak_url);
setLoading(false);
} catch (error) {
console.error('Ошибка при получении данных игрока:', error);
setSkin('');
setCape('');
}
};
const loadCapesData = async (username: string) => {
try {
setLoading(true);
const capesData = await fetchCapes(username);
setCapes(capesData);
setLoading(false);
} catch (error) {
console.error('Ошибка при получении плащей:', error);
setCapes([]);
}
};
// Обработка перетаскивания файла
const handleFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0];
if (file.type === 'image/png') {
setSkinFile(file);
setStatusMessage(`Файл "${file.name}" готов к загрузке`);
} else {
setStatusMessage('Пожалуйста, выберите файл в формате PNG');
setUploadStatus('error');
}
}
};
// Обработка выбора файла
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
if (file.type === 'image/png') {
setSkinFile(file);
setStatusMessage(`Файл "${file.name}" готов к загрузке`);
} else {
setStatusMessage('Пожалуйста, выберите файл в формате PNG');
setUploadStatus('error');
}
}
};
const handleActivateCape = async (cape_id: string) => {
setLoading(true);
await activateCape(username, cape_id);
await loadCapesData(username);
setLoading(false);
};
const handleDeactivateCape = async (cape_id: string) => {
setLoading(true);
await deactivateCape(username, cape_id);
await loadCapesData(username);
await loadPlayerData(uuid);
setLoading(false);
};
// Отправка запроса на установку скина
const handleUploadSkin = async () => {
setLoading(true);
if (!skinFile || !username) {
const msg = 'Необходимо выбрать файл и указать имя пользователя';
setStatusMessage(msg);
setUploadStatus('error');
setLoading(false);
// notification
if (!isNotificationsEnabled()) return;
setNotifMsg(msg);
setNotifSeverity('error');
setNotifOpen(true);
return;
}
setUploadStatus('loading');
try {
await uploadSkin(username, skinFile, skinModel);
setStatusMessage('Скин успешно загружен!');
setUploadStatus('success');
// 1) подтягиваем свежий skin_url с бэка
const config = JSON.parse(localStorage.getItem('launcher_config') || '{}');
if (config.uuid) {
await loadPlayerData(config.uuid);
}
// 2) сообщаем TopBar'у, что скин обновился
window.dispatchEvent(new CustomEvent('skin-updated'));
// notification
if (!isNotificationsEnabled()) return;
setNotifMsg('Скин успешно загружен!');
setNotifSeverity('success');
setNotifPos(getNotifPositionFromSettings());
setNotifOpen(true);
} catch (error) {
const msg = `Ошибка: ${
error instanceof Error ? error.message : 'Не удалось загрузить скин'
}`;
setStatusMessage(msg);
setUploadStatus('error');
// notification
if (!isNotificationsEnabled()) return;
setNotifMsg(msg);
setNotifSeverity('error');
setNotifPos(getNotifPositionFromSettings());
setNotifOpen(true);
} finally {
setLoading(false);
}
};
const GRADIENT =
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
useEffect(() => {
const STORAGE_KEY = 'launcher_settings';
const read = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const s = raw ? JSON.parse(raw) : null;
setAutoRotate(s?.autoRotateSkinViewer ?? true);
setWalkingSpeed(s?.walkingSpeed ?? 0.5);
} catch {
setAutoRotate(true);
setWalkingSpeed(0.5);
}
};
read();
// если хочешь, чтобы обновлялось сразу, когда Settings сохраняют:
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) read();
};
window.addEventListener('storage', onStorage);
// и наш “локальный” евент (для Electron/одного окна storage может не стрелять)
const onSettingsUpdated = () => read();
window.addEventListener('settings-updated', onSettingsUpdated as EventListener);
return () => {
window.removeEventListener('storage', onStorage);
window.removeEventListener('settings-updated', onSettingsUpdated as EventListener);
};
}, []);
return (
<Box
sx={{
mt: '10vh',
width: '100%',
height: '100%',
overflowY: 'auto',
boxSizing: 'border-box',
px: '2vw',
}}
>
<CustomNotification
open={notifOpen}
message={notifMsg}
severity={notifSeverity}
position={notifPos}
onClose={() => setNotifOpen(false)}
autoHideDuration={2500}
/>
{loading ? (
<FullScreenLoader message="Загрузка вашего профиля" />
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: '3vw',
alignItems: 'start',
}}
>
{/* LEFT COLUMN */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1vw',
minWidth: 0,
}}
>
{/* Плашка с ником */}
<Typography
sx={{
fontFamily: 'Benzin-Bold',
alignSelf: 'center',
justifySelf: 'center',
textAlign: 'center',
fontSize: '3vw',
position: 'relative',
px: '5vw',
py: '0.9vw',
borderRadius: '3vw',
color: 'rgba(255,255,255,0.95)',
background:
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.20), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.16), transparent 55%), rgba(10,10,20,0.92)',
border: '1px solid rgba(255,255,255,0.10)',
boxShadow: '0 1.4vw 3.8vw rgba(0,0,0,0.55)',
backdropFilter: 'blur(14px)',
overflow: 'hidden',
'&:after': {
content: '""',
position: 'absolute',
left: '8%',
right: '8%',
bottom: 0,
height: '0.35vw',
borderRadius: '999px',
background: GRADIENT,
opacity: 0.9,
},
}}
>
{username}
</Typography>
{/* SkinViewer */}
<Box
sx={{
overflow: 'hidden',
display: 'flex',
justifyContent: 'center',
}}
>
<SkinViewer
width={340}
height={405}
skinUrl={skin}
capeUrl={cape}
walkingSpeed={walkingSpeed}
autoRotate={autoRotate}
/>
</Box>
{/* Загрузчик скинов */}
<Box
sx={{
width: '100%',
p: '2.2vw',
borderRadius: '1.2vw',
boxSizing: 'border-box',
minWidth: 0,
overflow: 'hidden',
position: 'relative',
background:
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
backdropFilter: 'blur(14px)',
}}
>
{/* dropzone */}
<Box
sx={{
borderRadius: '1.1vw',
p: '1.6vw',
mb: '1.1vw',
textAlign: 'center',
cursor: 'pointer',
position: 'relative',
overflow: 'hidden',
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.10)',
transition:
'transform 0.18s ease, border-color 0.18s ease, background 0.18s ease',
'&:hover': {
transform: 'scale(1.005)',
borderColor: 'rgba(242,113,33,0.35)',
background: 'rgba(255,255,255,0.05)',
},
...(isDragOver
? {
borderColor: 'rgba(233,64,205,0.55)',
background:
'linear-gradient(120deg, rgba(242,113,33,0.10), rgba(233,64,205,0.08), rgba(138,35,135,0.10))',
}
: null),
'&:after': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '0.35vw',
background: GRADIENT,
opacity: 0.9,
pointerEvents: 'none',
},
}}
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleFileDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
type="file"
ref={fileInputRef}
accept=".png"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Typography
sx={{
color: 'rgba(255,255,255,0.92)',
fontWeight: 800,
}}
>
{skinFile
? `Выбран файл: ${skinFile.name}`
: 'Перетащите PNG файл скина или кликните для выбора'}
</Typography>
<Typography
sx={{
mt: 0.6,
color: 'rgba(255,255,255,0.60)',
fontWeight: 700,
fontSize: '0.9vw',
}}
>
Только .png Рекомендуется 64×64
</Typography>
</Box>
{/* select */}
<FormControl
fullWidth
size="small"
sx={{
mb: '1.1vw',
'& .MuiInputLabel-root': {
color: 'rgba(255,255,255,0.75)',
fontFamily: 'Benzin-Bold',
},
'& .MuiInputLabel-root.Mui-focused': {
color: 'rgba(242,113,33,0.95)',
},
}}
>
<InputLabel>Модель скина</InputLabel>
<Select
value={skinModel}
label="Модель скина"
onChange={(e) => setSkinModel(e.target.value)}
MenuProps={{
PaperProps: {
sx: {
bgcolor: 'rgba(10,10,20,0.96)',
border: '1px solid rgba(255,255,255,0.10)',
borderRadius: '1vw',
backdropFilter: 'blur(14px)',
'& .MuiMenuItem-root': {
color: 'rgba(255,255,255,0.9)',
fontFamily: 'Benzin-Bold',
},
'& .MuiMenuItem-root.Mui-selected': {
backgroundColor: 'rgba(242,113,33,0.16)',
},
'& .MuiMenuItem-root:hover': {
backgroundColor: 'rgba(233,64,205,0.14)',
},
},
},
}}
sx={{
borderRadius: '999px',
bgcolor: 'rgba(255,255,255,0.04)',
color: 'rgba(255,255,255,0.92)',
fontFamily: 'Benzin-Bold',
'& .MuiSelect-select': {
py: '0.9vw',
px: '1.2vw',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(255,255,255,0.14)',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(242,113,33,0.55)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(233,64,205,0.65)',
borderWidth: '2px',
},
'& .MuiSelect-icon': {
color: 'rgba(255,255,255,0.75)',
},
}}
>
<MenuItem value="">По умолчанию</MenuItem>
<MenuItem value="slim">Тонкая (Alex)</MenuItem>
<MenuItem value="classic">Классическая (Steve)</MenuItem>
</Select>
</FormControl>
{/* button */}
<Button
variant="contained"
fullWidth
onClick={handleUploadSkin}
disabled={uploadStatus === 'loading' || !skinFile}
disableRipple
sx={{
borderRadius: '2.5vw',
py: '0.95vw',
fontFamily: 'Benzin-Bold',
color: '#fff',
background: GRADIENT,
boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.45)',
transition:
'transform 0.18s ease, filter 0.18s ease, opacity 0.18s ease',
'&:hover': {
transform: 'scale(1.01)',
filter: 'brightness(1.05)',
},
'&.Mui-disabled': {
background: 'rgba(255,255,255,0.10)',
color: 'rgba(255,255,255,0.55)',
},
}}
>
{uploadStatus === 'loading' ? 'Загрузка...' : 'Установить скин'}
</Button>
</Box>
</Box>
{/* RIGHT COLUMN */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1vw',
minWidth: 0,
maxWidth: '44vw',
justifySelf: 'start',
}}
>
{/* Плащи */}
<Paper
elevation={0}
sx={{
p: '1.6vw',
borderRadius: '1.2vw',
background:
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
backdropFilter: 'blur(14px)',
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.35vw',
lineHeight: 1.1,
backgroundImage: GRADIENT,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mb: '1.0vw',
}}
>
Ваши плащи
</Typography>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: '1.2vw',
}}
>
{capes.map((cape) => (
<CapeCard
key={cape.cape_id}
cape={cape}
mode="profile"
onAction={cape.is_active ? handleDeactivateCape : handleActivateCape}
actionDisabled={loading}
/>
))}
</Box>
</Paper>
{/* Онлайн */}
<OnlinePlayersPanel currentUsername={username} />
</Box>
</Box>
)}
</Box>
);
}

View 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>
</>
);
};

View File

@ -0,0 +1,908 @@
import Stepper from '@mui/material/Stepper';
import Step from '@mui/material/Step';
import StepLabel from '@mui/material/StepLabel';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
StepConnector,
stepConnectorClasses,
StepIconProps,
styled,
Typography,
Box,
Button,
Paper,
Stack,
Divider,
} from '@mui/material';
import { alpha, keyframes } from '@mui/material/styles';
import LoginRoundedIcon from '@mui/icons-material/LoginRounded';
import VerifiedRoundedIcon from '@mui/icons-material/VerifiedRounded';
import AssignmentIndRoundedIcon from '@mui/icons-material/AssignmentIndRounded';
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
import QRCodeStyling from 'qr-code-styling';
import useAuth from '../hooks/useAuth';
import useConfig from '../hooks/useConfig';
import {
generateVerificationCode,
registerUser,
getVerificationStatus,
} from '../api';
import popalogo from '../../../assets/icons/popa-popa.svg';
import GradientTextField from '../components/GradientTextField';
import { FullScreenLoader } from '../components/FullScreenLoader';
import CustomNotification from '../components/Notifications/CustomNotification';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
import {
isNotificationsEnabled,
getNotifPositionFromSettings,
} from '../utils/notifications';
import { useNavigate } from 'react-router-dom';
import { upsertPending, removePending, loadPending } from '../utils/pendingVerification';
/* =======================
Shared “premium” styling
======================= */
const GRADIENT =
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
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 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 SoftButton = styled(Button)(() => ({
borderRadius: 999,
fontFamily: 'Benzin-Bold, sans-serif',
color: '#fff',
border: '1px solid rgba(255,255,255,0.12)',
background: 'rgba(255,255,255,0.06)',
textTransform: 'none',
'&:hover': { background: 'rgba(255,255,255,0.08)' },
}));
/* =======================
Stepper styling (your base)
======================= */
const ColorlibConnector = styled(StepConnector)(({ theme }) => ({
[`&.${stepConnectorClasses.alternativeLabel}`]: { top: 22 },
[`&.${stepConnectorClasses.active}`]: {
[`& .${stepConnectorClasses.line}`]: {
backgroundImage:
'linear-gradient( 95deg,rgb(150,150,150) 0%, rgb(242,113,33) 80%,rgb(233,64,87) 110%,rgb(138,35,135) 150%)',
},
},
[`&.${stepConnectorClasses.completed}`]: {
[`& .${stepConnectorClasses.line}`]: {
backgroundImage:
'linear-gradient( 95deg,rgb(242,113,33) 0%,rgb(233,64,87) 50%,rgb(138,35,135) 100%)',
},
},
[`& .${stepConnectorClasses.line}`]: {
height: 3,
border: 0,
backgroundImage:
'linear-gradient( 275deg,rgb(150,150,150) 0%, rgb(242,113,33) 80%,rgb(233,64,87) 110%,rgb(138,35,135) 150%)',
borderRadius: 1,
transition: 'background-image 1s ease, background-color 1s ease',
...theme.applyStyles('dark', {
backgroundColor: theme.palette.grey[800],
}),
},
}));
const ColorlibStepIconRoot = styled('div')<{
ownerState: { completed?: boolean; active?: boolean };
}>(({ theme }) => ({
backgroundColor: '#adadad',
zIndex: 1,
color: '#fff',
width: 50,
height: 50,
display: 'flex',
borderRadius: '50%',
justifyContent: 'center',
alignItems: 'center',
transition: 'background-image 1s ease, box-shadow 1s ease, transform 1s ease',
...theme.applyStyles('dark', {
backgroundColor: theme.palette.grey[700],
}),
variants: [
{
props: ({ ownerState }) => ownerState.active,
style: {
backgroundImage:
'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
boxShadow: '0 4px 10px 0 rgba(0,0,0,.25)',
transform: 'scale(1.08)',
},
},
{
props: ({ ownerState }) => ownerState.completed,
style: { backgroundImage: '#adadad' },
},
],
}));
function ColorlibStepIcon(props: StepIconProps) {
const { active, completed, className } = props;
const icons: { [index: string]: React.ReactElement<unknown> } = {
1: <AssignmentIndRoundedIcon />,
2: <VerifiedRoundedIcon />,
3: <LoginRoundedIcon />,
};
return (
<ColorlibStepIconRoot ownerState={{ completed, active }} className={className}>
{icons[String(props.icon)]}
</ColorlibStepIconRoot>
);
}
/* =======================
Component
======================= */
export const Registration = () => {
const navigate = useNavigate();
const auth = useAuth();
const { saveConfig } = useConfig();
const [activeStep, setActiveStep] = useState(0);
const steps = useMemo(
() => ['Создание аккаунта', 'Верификация в Telegram'],
[],
);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [enterpassword, setEnterPassword] = useState('');
const [verificationCode, setVerificationCode] = useState<string | null>(null);
const [url, setUrl] = useState('');
const [verifyState, setVerifyState] = useState<'idle' | 'waiting' | 'verified'>('idle');
// 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',
});
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);
};
// QR
const qrRef = useRef<HTMLDivElement | null>(null);
const qrInstanceRef = useRef<QRCodeStyling | null>(null);
const pollTimerRef = useRef<number | null>(null);
const stopPolling = () => {
if (pollTimerRef.current) {
window.clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
};
useEffect(() => () => stopPolling(), []);
// init qr instance once
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',
},
});
}
}, []);
// append QR when step 1 (verification)
useEffect(() => {
if (activeStep !== 1) return;
if (!qrRef.current || !qrInstanceRef.current) return;
while (qrRef.current.firstChild) qrRef.current.removeChild(qrRef.current.firstChild);
qrInstanceRef.current.append(qrRef.current);
}, [activeStep]);
useEffect(() => {
if (!qrInstanceRef.current) return;
if (!url) return;
qrInstanceRef.current.update({ data: url });
}, [url]);
const handleCreateAccount = async () => {
if (!username || !password || !enterpassword) {
showNotification('Заполните все поля', 'warning');
return;
}
if (password !== enterpassword) {
showNotification('Пароли не совпадают', 'warning');
return;
}
try {
const response = await registerUser(username, password);
if (response.status === 'success') {
upsertPending({ username, password, createdAt: Date.now() });
setActiveStep(1);
showNotification('Аккаунт создан. Перейдите к верификации.', 'success');
} else {
showNotification(String(response.status), 'error');
}
} catch {
showNotification('Ошибка регистрации', 'error');
}
};
const copyVerificationCode = async () => {
if (!verificationCode) return;
try {
await navigator.clipboard.writeText(verificationCode);
showNotification('Код скопирован', 'success');
} catch {
// fallback для старых браузеров / запретов clipboard
try {
const ta = document.createElement('textarea');
ta.value = verificationCode;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showNotification('Код скопирован', 'success');
} catch {
showNotification('Не удалось скопировать код', 'error');
}
}
};
const handleGenerateVerificationCode = async (u: string) => {
const response = await generateVerificationCode(u);
setVerificationCode(response.code);
};
const handleVerifyCode = async () => {
try {
const response = await getVerificationStatus(username);
if (!response.is_verified) {
setVerifyState('waiting');
return;
}
setVerifyState('verified');
stopPolling();
showNotification('Аккаунт подтверждён! Входим…', 'success');
removePending(username);
saveConfig({ username, password });
await auth.authenticateUser(username, password, saveConfig);
navigate('/', { replace: true });
} catch (e: any) {
// если верификация ок, но логин не вышел
console.error(e);
showNotification('Аккаунт подтверждён, но вход не удался. Попробуй войти вручную.', 'warning');
navigate('/login', { replace: true });
}
};
useEffect(() => {
// если user вернулся/зашел заново на регистрацию — восстановим pending
const list = loadPending();
if (!list.length) return;
// Берём самый свежий
const last = list[0];
// Если мы еще на шаге 0 — можно предложить / автоперейти
setUsername(last.username);
if (last.password) setPassword(last.password);
// сразу на verification
setActiveStep(1);
}, []);
const startVerificationFlow = async () => {
if (!username) return;
setVerifyState('idle');
setVerificationCode(null);
const newUrl = `https://t.me/popa_popa_popa_bot?start=${username}`;
setUrl(newUrl);
try {
await handleGenerateVerificationCode(username);
setVerifyState('waiting');
} catch {
showNotification('Не удалось получить код верификации', 'error');
}
stopPolling();
pollTimerRef.current = window.setInterval(() => {
handleVerifyCode();
}, 5000);
};
// when switch to verification step
useEffect(() => {
if (activeStep !== 1) return;
startVerificationFlow();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeStep]);
const handleOpenBot = () => {
window.open(`https://t.me/popa_popa_popa_bot?start=${username}`, '_blank');
};
return (
<Box
sx={{
width: '100%',
minHeight: 'calc(100vh - 8vh)',
display: 'grid',
placeItems: 'center',
px: '2vw',
pb: '4vh',
}}
>
<GlassPaper sx={{ width: 'min(72vw, 1040px)', borderRadius: '2.2vw' }}>
<Glow />
<Box sx={{ position: 'relative', p: '2.2vw' }}>
{/* Header */}
<Stack spacing={0.8} alignItems="center" sx={{ mb: '1.4vw' }}>
<GradientTitle sx={{ fontSize: 'clamp(18px, 1.7vw, 26px)' }}>
Регистрация
</GradientTitle>
<Typography
sx={{
color: 'rgba(255,255,255,0.70)',
fontWeight: 700,
textAlign: 'center',
fontSize: 'clamp(12px, 0.95vw, 14px)',
maxWidth: '60ch',
}}
>
Создай аккаунт и подтверди его в Telegram это займёт минуту.
</Typography>
</Stack>
{/* Stepper inside card (not absolute) */}
<Box sx={{ mb: '1.6vw' }}>
<Stepper
activeStep={activeStep}
alternativeLabel
connector={<ColorlibConnector />}
sx={{
'& .MuiStepLabel-label': {
color: '#adadad !important',
transition: 'color 1s ease',
},
'& .Mui-completed': {
color: '#adadad !important',
},
'& .Mui-active': {
backgroundImage:
'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
transition: 'all 1s ease',
},
}}
>
{steps.map((label) => (
<Step key={label}>
<StepLabel StepIconComponent={ColorlibStepIcon}>
{label}
</StepLabel>
</Step>
))}
</Stepper>
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)', mb: '1.6vw' }} />
{/* Content */}
{activeStep === 0 ? (
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
gap: '1.6vw',
alignItems: 'start',
}}
>
{/* Left: form card */}
<Box
sx={{
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={username}
onChange={(e) => setUsername(e.target.value)}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
display: 'none',
},
'& .MuiInputLabel-root.MuiInputLabel-shrink': {
display: 'none',
},
}}
/>
<GradientTextField
label="Пароль"
required
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
display: 'none',
},
'& .MuiInputLabel-root.MuiInputLabel-shrink': {
display: 'none',
},
}}
/>
<GradientTextField
label="Подтвердите пароль"
required
name="enterpassword"
type="password"
value={enterpassword}
onChange={(e) => setEnterPassword(e.target.value)}
error={Boolean(enterpassword) && password !== enterpassword}
helperText={
Boolean(enterpassword) && password !== enterpassword
? 'Пароли не совпадают'
: ''
}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
display: 'none',
},
'& .MuiInputLabel-root.MuiInputLabel-shrink': {
display: 'none',
},
}}
/>
<GradientButton
variant="contained"
onClick={handleCreateAccount}
sx={{
py: 1.2,
fontSize: 'clamp(12px, 0.95vw, 14px)',
mt: 0.6,
}}
>
Создать аккаунт
</GradientButton>
<SoftButton
onClick={() => navigate('/login')}
sx={{ py: 1.1, fontSize: 'clamp(12px, 0.92vw, 14px)' }}
>
Уже есть аккаунт? Войти
</SoftButton>
</Stack>
</Box>
{/* Right: tips card */}
<Box
sx={{
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}>
<Typography sx={{ color: 'rgba(255,255,255,0.75)', fontWeight: 700 }}>
1) Создаёшь аккаунт
</Typography>
<Typography sx={{ color: 'rgba(255,255,255,0.70)' }}>
Придумай никнейм и пароль.
</Typography>
<Typography sx={{ color: 'rgba(255,255,255,0.75)', fontWeight: 700, mt: 1 }}>
2) Подтверждаешь в Telegram
</Typography>
<Typography sx={{ color: 'rgba(255,255,255,0.70)' }}>
Мы покажем QR и код введёшь код в боте, и всё готово.
</Typography>
<Box
sx={{
mt: 1.2,
borderRadius: 2,
border: '1px dashed rgba(255,255,255,0.18)',
p: 1.2,
background: 'rgba(255,255,255,0.04)',
}}
>
<Typography sx={{ color: 'rgba(255,255,255,0.72)', fontSize: 13 }}>
Подсказка: если Telegram не на этом устройстве нажми Открыть бота
и продолжай в браузере/приложении.
</Typography>
</Box>
</Stack>
</Box>
</Box>
) : (
// Step 1: Verification
<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',
}}
>
<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' }}>
<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',
}}
>
{/* shimmer border */}
<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: 0.45,
}}
/>
<div ref={qrRef} style={{ minHeight: 300 }} />
</Box>
<Typography
sx={{
mt: 1,
textAlign: 'center',
fontSize: 'clamp(12px, 0.9vw, 14px)',
color: alpha('#fff', 0.75),
fontWeight: 700,
}}
>
{verifyState === 'verified'
? 'Подтверждено — перенаправляем…' : (verificationCode) ? 'Ждём ответ от бота...'
: 'Сканируй QR в Telegram'}
</Typography>
</Box>
</Box>
{/* Actions & code card */}
<Stack spacing={1.2} sx={{ pt: { xs: 0, md: '0.4vw' } }}>
<Stack direction="row" spacing={1} alignItems="center">
<GradientTitle sx={{ fontSize: 'clamp(16px, 1.2vw, 18px)' }}>
Верификация
</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:
verifyState === 'waiting'
? 'rgba(255,255,255,0.06)'
: verifyState === 'verified'
? 'rgba(60,255,170,0.12)'
: 'rgba(255,255,255,0.06)',
}}
>
{verifyState === 'waiting'
? 'ожидание'
: verifyState === 'verified'
? 'готово'
: 'старт'}
</Box>
</Stack>
<Typography
sx={{
color: 'rgba(255,255,255,0.70)',
fontWeight: 700,
fontSize: 'clamp(12px, 0.9vw, 14px)',
lineHeight: 1.35,
}}
>
1) Открой бота <br />
2) Введи код ниже <br />
3) Жди подтверждения
</Typography>
<GradientButton
variant="contained"
onClick={handleOpenBot}
startIcon={<OpenInNewRoundedIcon />}
disabled={!username}
sx={{ py: 1.2, fontSize: 'clamp(12px, 0.95vw, 14px)' }}
>
Открыть бота
</GradientButton>
<SoftButton
onClick={startVerificationFlow}
startIcon={<RefreshRoundedIcon />}
sx={{ py: 1.1, fontSize: 'clamp(12px, 0.92vw, 14px)' }}
>
Обновить код / перезапустить
</SoftButton>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)', my: 0.6 }} />
<Box
sx={{
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',
textAlign: 'center',
mt: '5vw',
}}
>
<Typography sx={{ color: 'rgba(255,255,255,0.75)', fontWeight: 800 }}>
Код верификации
</Typography>
{verificationCode && (
<Typography
onClick={copyVerificationCode}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') copyVerificationCode();
}}
sx={{
mt: 0.4,
fontSize: 'clamp(28px, 2.4vw, 44px)',
fontFamily: 'Benzin-Bold, sans-serif',
backgroundImage:
'linear-gradient( 136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
cursor: 'pointer',
userSelect: 'none',
position: 'relative',
display: 'inline-block',
// “наведение”
'&:hover': {
transform: 'scale(1.03)',
filter: 'brightness(1.08)',
},
// “нажатие”
'&:active': {
transform: 'scale(0.99)',
},
// фокус для клавиатуры
outline: 'none',
'&:focus-visible': {
textShadow: '0 0 18px rgba(233,64,205,0.35)',
},
transition: 'transform 120ms ease, filter 120ms ease, text-shadow 120ms ease',
}}
>
{verificationCode}
</Typography>
)}
<Typography sx={{ mt: 0.4, color: 'rgba(255,255,255,0.55)', fontSize: 12 }}>
Нажми на код, чтобы скопировать
</Typography>
</Box>
<SoftButton
onClick={() => navigate('/login', { replace: true })}
sx={{ py: 1.0, fontSize: 'clamp(12px, 0.92vw, 14px)' }}
>
Перейти ко входу
</SoftButton>
</Stack>
</Box>
)}
</Box>
</GlassPaper>
<CustomNotification
open={notifOpen}
message={notifMsg}
severity={notifSeverity}
position={notifPos}
onClose={() => setNotifOpen(false)}
autoHideDuration={2500}
/>
</Box>
);
};

View 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;

1224
src/renderer/pages/Shop.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,488 @@
import { useEffect, useState } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardContent,
Button,
Modal,
List,
ListItem,
ListItemText,
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import AddIcon from '@mui/icons-material/Add';
import { FullScreenLoader } from '../components/FullScreenLoader';
interface VersionCardProps {
id: string;
name: string;
imageUrl: string;
version: string;
onSelect: (id: string) => void;
isHovered: boolean;
onHover: (id: string | null) => void;
hoveredCardId: string | null;
}
const gradientPrimary =
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
export const VersionCard: React.FC<VersionCardProps> = ({
id,
name,
imageUrl, // пока не используется, но оставляем для будущего
version,
onSelect,
isHovered,
onHover,
hoveredCardId,
}) => {
return (
<Card
sx={{
background:
'radial-gradient(circle at top left, rgba(242,113,33,0.2), transparent 55%), rgba(10,10,20,0.95)',
backdropFilter: 'blur(18px)',
width: '35vw',
height: '35vh',
minWidth: 'unset',
minHeight: 'unset',
display: 'flex',
flexDirection: 'column',
borderRadius: '2.5vw',
boxShadow: isHovered
? '0 0 10px rgba(233,64,205,0.55)'
: '0 14px 40px rgba(0, 0, 0, 0.6)',
transition:
'transform 0.35s ease, box-shadow 0.35s ease, border-color 0.35s ease',
overflow: 'hidden',
cursor: 'pointer',
transform: isHovered ? 'scale(1.04)' : 'scale(1)',
zIndex: isHovered ? 10 : 1,
'&:hover': {
borderColor: 'rgba(242,113,33,0.8)',
},
}}
onClick={() => onSelect(id)}
onMouseEnter={() => onHover(id)}
onMouseLeave={() => onHover(null)}
>
<CardContent
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1vh',
textAlign: 'center',
}}
>
<Typography
gutterBottom
variant="h5"
component="div"
sx={{
fontWeight: 'bold',
fontFamily: 'Benzin-Bold',
fontSize: '2vw',
backgroundImage: gradientPrimary,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{name}
</Typography>
<Typography
variant="subtitle1"
sx={{
color: 'rgba(255,255,255,0.8)',
fontSize: '1.1vw',
}}
>
Версия {version}
</Typography>
</CardContent>
</Card>
);
};
interface VersionInfo {
id: string;
name: string;
version: string;
imageUrl?: string;
config?: {
downloadUrl: string;
apiReleaseUrl: string;
versionFileName: string;
packName: string;
memory: number;
baseVersion: string;
serverIp: string;
fabricVersion: string;
preserveFiles: string[];
};
}
interface AvailableVersionInfo {
id: string;
name: string;
version: string;
imageUrl?: string;
config: {
downloadUrl: string;
apiReleaseUrl: string;
versionFileName: string;
packName: string;
memory: number;
baseVersion: string;
serverIp: string;
fabricVersion: string;
preserveFiles: string[];
};
}
// В компоненте VersionsExplorer
export const VersionsExplorer = () => {
const [installedVersions, setInstalledVersions] = useState<VersionInfo[]>([]);
const [availableVersions, setAvailableVersions] = useState<
AvailableVersionInfo[]
>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [downloadLoading, setDownloadLoading] = useState<string | null>(null);
const [hoveredCardId, setHoveredCardId] = useState<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
const fetchVersions = async () => {
try {
setLoading(true);
// Получаем список установленных версий через IPC
const installedResult = await window.electron.ipcRenderer.invoke(
'get-installed-versions',
);
if (installedResult.success) {
setInstalledVersions(installedResult.versions);
}
// Получаем доступные версии с GitHub Gist
const availableResult = await window.electron.ipcRenderer.invoke(
'get-available-versions',
{
gistUrl:
'https://gist.githubusercontent.com/DIKER0K/06cd12fb3a4d08b1f0f8c763a7d05e06/raw/versions.json',
},
);
if (availableResult.success) {
setAvailableVersions(availableResult.versions);
}
} catch (error) {
console.error('Ошибка при загрузке версий:', error);
} finally {
setLoading(false);
}
};
fetchVersions();
}, []);
const handleSelectVersion = (version: VersionInfo | AvailableVersionInfo) => {
const cfg: any = (version as any).config;
if (cfg && (cfg.downloadUrl || cfg.apiReleaseUrl)) {
localStorage.setItem('selected_version_config', JSON.stringify(cfg));
} else {
localStorage.removeItem('selected_version_config');
}
navigate(`/launch/${version.id}`);
};
const handleAddVersion = () => {
setModalOpen(true);
};
const handleCloseModal = () => {
setModalOpen(false);
};
const handleDownloadVersion = async (version: AvailableVersionInfo) => {
try {
setDownloadLoading(version.id);
const downloadResult = await window.electron.ipcRenderer.invoke(
'download-and-extract',
{
downloadUrl: version.config.downloadUrl,
apiReleaseUrl: version.config.apiReleaseUrl,
versionFileName: version.config.versionFileName,
packName: version.id,
preserveFiles: version.config.preserveFiles || [],
},
);
if (downloadResult?.success) {
setInstalledVersions((prev) => [...prev, version]);
setModalOpen(false);
}
} catch (error) {
console.error(`Ошибка при скачивании версии ${version.id}:`, error);
} finally {
setDownloadLoading(null);
}
};
// Карточка добавления новой версии
const AddVersionCard = () => (
<Card
sx={{
background:
'radial-gradient(circle at top left, rgba(233,64,205,0.3), rgba(10,10,20,0.95))',
width: '35vw',
height: '35vh',
display: 'flex',
flexDirection: 'column',
borderRadius: '2.5vw',
position: 'relative',
border: 'none',
boxShadow: '0 14px 40px rgba(0, 0, 0, 0.6)',
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
transition: 'transform 0.35s ease, box-shadow 0.35s ease',
willChange: 'transform, box-shadow',
'&:hover': {
boxShadow: '0 0 40px rgba(242,113,33,0.7)',
transform: 'scale(1.02)',
zIndex: 10,
},
}}
onClick={handleAddVersion}
>
<AddIcon sx={{ fontSize: '4vw', color: '#fff' }} />
<Typography
variant="h6"
sx={{
color: '#fff',
fontFamily: 'Benzin-Bold',
fontSize: '1.5vw',
mt: 1,
}}
>
Добавить версию
</Typography>
</Card>
);
return (
<Box
sx={{
px: '5vw',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '2vh',
height: '100%',
width: '85%',
}}
>
{loading ? (
<FullScreenLoader message="Загрузка ваших версий..." />
) : (
<Grid
container
spacing={3}
sx={{
width: '100%',
height: '100%',
overflowY: 'auto',
justifyContent: 'center',
alignContent: 'flex-start',
position: 'relative',
zIndex: 1,
pt: '3vh',
}}
>
{installedVersions.length > 0 ? (
installedVersions.map((version) => (
<Grid
key={version.id}
item
xs={12}
sm={6}
md={4}
sx={{
display: 'flex',
justifyContent: 'center',
marginBottom: '2vh',
}}
>
<VersionCard
id={version.id}
name={version.name}
imageUrl={
version.imageUrl ||
'https://via.placeholder.com/300x140?text=Minecraft'
}
version={version.version}
onSelect={() => handleSelectVersion(version)}
isHovered={hoveredCardId === version.id}
onHover={setHoveredCardId}
hoveredCardId={hoveredCardId}
/>
</Grid>
))
) : (
<Grid
item
xs={12}
sm={6}
md={4}
sx={{
display: 'flex',
justifyContent: 'center',
marginBottom: '2vh',
}}
>
<AddVersionCard />
</Grid>
)}
{installedVersions.length > 0 && (
<Grid
item
xs={12}
sm={6}
md={4}
sx={{
display: 'flex',
justifyContent: 'center',
marginBottom: '2vh',
}}
>
<AddVersionCard />
</Grid>
)}
</Grid>
)}
{/* Модальное окно для выбора версии для скачивания */}
<Modal
open={modalOpen}
onClose={handleCloseModal}
aria-labelledby="modal-versions"
aria-describedby="modal-available-versions"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 420,
maxWidth: '90vw',
maxHeight: '80vh',
overflowY: 'auto',
background: 'linear-gradient(145deg, #000000 10%, #8A2387 100%)',
boxShadow: '0 20px 60px rgba(0,0,0,0.85)',
p: 4,
borderRadius: '2.5vw',
gap: '1.5vh',
display: 'flex',
flexDirection: 'column',
backdropFilter: 'blur(18px)',
}}
>
<Typography
variant="h6"
component="h2"
sx={{
color: '#fff',
fontFamily: 'Benzin-Bold',
mb: 1,
}}
>
Доступные версии для скачивания
</Typography>
{availableVersions.length === 0 ? (
<Typography sx={{ color: '#fff', mt: 2 }}>
Загрузка доступных версий...
</Typography>
) : (
<List sx={{ mt: 1 }}>
{availableVersions.map((version) => (
<ListItem
key={version.id}
sx={{
borderRadius: '1vw',
mb: 1,
backgroundColor: 'rgba(0, 0, 0, 0.35)',
border: '1px solid rgba(20,20,20,0.2)',
cursor: 'pointer',
transition:
'background-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
transform: 'translateY(-2px)',
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
},
}}
onClick={() => handleSelectVersion(version)}
>
<ListItemText
primary={version.name}
secondary={version.version}
primaryTypographyProps={{
color: '#fff',
fontFamily: 'Benzin-Bold',
}}
secondaryTypographyProps={{
color: 'rgba(255,255,255,0.7)',
}}
/>
{downloadLoading === version.id && (
<Typography
variant="body2"
sx={{ color: 'rgba(255,255,255,0.7)' }}
>
Загрузка...
</Typography>
)}
</ListItem>
))}
</List>
)}
<Button
onClick={handleCloseModal}
variant="contained"
sx={{
mt: 3,
alignSelf: 'center',
px: 6,
py: 1.2,
borderRadius: '2.5vw',
background: gradientPrimary,
fontFamily: 'Benzin-Bold',
fontSize: '1vw',
textTransform: 'none',
'&:hover': {
transform: 'scale(1.01)',
boxShadow: '0 10px 30px rgba(0,0,0,0.6)',
},
}}
>
Закрыть
</Button>
</Box>
</Modal>
</Box>
);
};

View 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>
);
}

View File

@ -0,0 +1,3 @@
export const rtcConfig: RTCConfiguration = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
};

View 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;
};
};

View 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,
};
}

View 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);
};
}

View 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';
}

View 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)',
};

View 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;
};

View 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;
}

View 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,
}));
}

View 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' };
}
}

Some files were not shown because too many files have changed in this diff Show More