add settings, redesign settings panel

This commit is contained in:
aurinex
2025-12-15 22:41:38 +05:00
parent 6adc64dab8
commit cd7ad5039e
5 changed files with 437 additions and 147 deletions

View File

@ -9,7 +9,7 @@
* `./src/main.js` using webpack. This gives us some performance wins.
*/
import path from 'path';
import { app, BrowserWindow, shell, ipcMain } from 'electron';
import { app, BrowserWindow, shell, ipcMain, Tray, Menu, nativeImage } from 'electron';
import { autoUpdater } from 'electron-updater';
import log from 'electron-log';
import MenuBuilder from './menu';
@ -58,6 +58,60 @@ class AppUpdater {
}
}
let launcherSettings = {
autoLaunch: false,
startInTray: false,
closeToTray: true,
};
const ensureTray = () => {
if (tray) return;
const RESOURCES_PATH = app.isPackaged
? path.join(process.resourcesPath, 'assets')
: path.join(__dirname, '../../assets');
const iconPath = path.join(RESOURCES_PATH, 'icon.png');
const image = nativeImage.createFromPath(iconPath);
tray = new Tray(image);
tray.setToolTip('Popa Launcher');
const menu = Menu.buildFromTemplate([
{
label: 'Открыть',
click: () => {
if (!mainWindow) return;
mainWindow.show();
mainWindow.focus();
},
},
{ type: 'separator' },
{
label: 'Выход',
click: () => 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 mainWindow: BrowserWindow | null = null;
ipcMain.on('ipc-example', async (event, arg) => {
@ -101,6 +155,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();
@ -130,6 +212,14 @@ const createWindow = async () => {
},
});
mainWindow.on('close', (e) => {
if (launcherSettings.closeToTray) {
e.preventDefault();
ensureTray();
mainWindow?.hide();
}
});
mainWindow.loadURL(resolveHtmlPath('index.html'));
mainWindow.on('ready-to-show', () => {
@ -139,7 +229,10 @@ const createWindow = async () => {
if (process.env.START_MINIMIZED) {
mainWindow.minimize();
} else {
mainWindow.show();
setTimeout(() => {
if (!mainWindow) return;
if (!launcherSettings.startInTray) mainWindow.show();
}, 2000);
}
});

View File

@ -19,6 +19,7 @@ export type Channels =
| 'overall-progress'
| 'stop-minecraft'
| 'minecraft-started'
| 'apply-launcher-settings'
| 'minecraft-stopped';
const electronHandler = {

View File

@ -100,14 +100,40 @@ const AuthCheck = ({ children }: { children: ReactNode }) => {
};
const App = () => {
const getInitialRoute = () => {
try {
const settingsRaw = localStorage.getItem('launcher_settings');
const settings = settingsRaw ? JSON.parse(settingsRaw) : null;
if (!settings?.rememberLastRoute) return ['/'];
const saved = localStorage.getItem('last_route');
return [saved || '/'];
} catch {
return ['/'];
}
};
return (
<Router>
<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 {
@ -116,15 +142,8 @@ const AppLayout = () => {
const settings = JSON.parse(raw);
document.body.classList.toggle(
'reduce-motion',
Boolean(settings.reduceMotion),
);
document.body.classList.toggle(
'no-blur',
settings.blurEffects === false,
);
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') {
@ -140,12 +159,33 @@ const AppLayout = () => {
}
};
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);
return () => window.removeEventListener('settings-updated', applySettings);
window.addEventListener('settings-updated', pushLauncherSettingsToMain);
return () => {
window.removeEventListener('settings-updated', applySettings);
window.removeEventListener('settings-updated', pushLauncherSettingsToMain);
};
}, []);
// Просто используйте window.open без useNavigate
@ -153,7 +193,6 @@ const AppLayout = () => {
window.open('https://account.ely.by/register', '_blank');
};
const [username, setUsername] = useState<string | null>(null);
const location = useLocation();
const path = location.pathname;
useEffect(() => {

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

@ -17,6 +17,7 @@ import {
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
@ -25,8 +26,9 @@ type SettingsState = {
blurEffects: boolean;
// Launcher / app
autoUpdate: boolean;
autoLaunch: boolean;
startInTray: boolean;
closeToTray: boolean;
// Game
autoRotateSkinViewer: boolean;
@ -35,6 +37,9 @@ type SettingsState = {
// Notifications
notifications: boolean;
notificationPosition: 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left';
// Navigation
rememberLastRoute: boolean;
};
const STORAGE_KEY = 'launcher_settings';
@ -42,19 +47,67 @@ 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',
'&: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,
autoUpdate: true,
startInTray: false,
autoLaunch: false,
closeToTray: true,
autoRotateSkinViewer: true,
walkingSpeed: 0.5,
notifications: true,
notificationPosition: 'bottom-center',
rememberLastRoute: true,
};
function safeParseSettings(raw: string | null): SettingsState | null {
@ -164,6 +217,40 @@ const mapNotifPosition = (
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;
@ -185,6 +272,11 @@ const Settings = () => {
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]);
@ -234,40 +326,6 @@ const Settings = () => {
document.body.classList.toggle('no-blur', !settings.blurEffects);
}, [settings.reduceMotion, settings.blurEffects]);
const SectionTitle = ({ children }: { children: string }) => (
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.25vw',
lineHeight: 1.1,
backgroundImage: GRADIENT,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mb: '0.9vw',
}}
>
{children}
</Typography>
);
const Glass = ({ children }: { children: React.ReactNode }) => (
<Paper
className="glass glass--soft"
elevation={0}
sx={{
borderRadius: '1.2vw',
overflow: 'hidden',
background:
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
color: 'white',
}}
>
<Box sx={{ p: '1.8vw' }}>{children}</Box>
</Paper>
);
const controlSx = {
'& .MuiFormControlLabel-label': {
fontFamily: 'Benzin-Bold',
@ -395,39 +453,24 @@ const Settings = () => {
max={120}
step={5}
onChange={(_, v) => setSettings((s) => ({ ...s, uiScale: v as number }))}
sx={{
mt: 0.4,
'& .MuiSlider-thumb': { boxShadow: '0 10px 22px rgba(0,0,0,0.45)' },
}}
sx={SLIDER_SX}
/>
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
<FormControlLabel
control={
<Switch
<SettingCheckboxRow
title="Уменьшить анимации"
description="Отключить все анимации лаунчера"
checked={settings.reduceMotion}
onChange={(e) =>
setSettings((s) => ({ ...s, reduceMotion: e.target.checked }))
}
/>
}
label="Уменьшить анимации"
sx={controlSx}
onChange={setFlag('reduceMotion')}
/>
<FormControlLabel
control={
<Switch
<SettingCheckboxRow
title="Эффекты размытия (blur)"
description="Компоненты будут прозрачными без размытия"
checked={settings.blurEffects}
onChange={(e) =>
setSettings((s) => ({ ...s, blurEffects: e.target.checked }))
}
/>
}
label="Эффекты размытия (blur)"
sx={controlSx}
onChange={setFlag('blurEffects')}
/>
</Box>
</Glass>
@ -436,17 +479,11 @@ const Settings = () => {
<SectionTitle>Уведомления</SectionTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
<FormControlLabel
control={
<Switch
<SettingCheckboxRow
title="Включить уведомления"
description="Уведомления о каких-либо действиях"
checked={settings.notifications}
onChange={(e) =>
setSettings((s) => ({ ...s, notifications: e.target.checked }))
}
/>
}
label="Включить уведомления"
sx={controlSx}
onChange={setFlag('notifications')}
/>
<NotificationPositionPicker
@ -475,19 +512,12 @@ const Settings = () => {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '2vw', minWidth: 0 }}>
<Glass>
<SectionTitle>Игра</SectionTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
<FormControlLabel
control={
<Switch
<SettingCheckboxRow
title="Автоповорот персонажа в профиле"
description="Прокрут игрового персонажа в профиле"
checked={settings.autoRotateSkinViewer}
onChange={(e) =>
setSettings((s) => ({ ...s, autoRotateSkinViewer: e.target.checked }))
}
/>
}
label="Автоповорот персонажа в профиле"
sx={controlSx}
onChange={setFlag('autoRotateSkinViewer')}
/>
<Box>
@ -500,13 +530,9 @@ const Settings = () => {
max={1}
step={0.05}
onChange={(_, v) => setSettings((s) => ({ ...s, walkingSpeed: v as number }))}
sx={{ mt: 0.4 }}
sx={SLIDER_SX}
/>
</Box>
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.9vw' }}>
Эти значения можно прокинуть в Profile: autoRotate и walkingSpeed.
</Typography>
</Box>
</Glass>
@ -514,54 +540,33 @@ const Settings = () => {
<SectionTitle>Лаунчер</SectionTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
<FormControlLabel
control={
<Switch
checked={settings.autoUpdate}
onChange={(e) => setSettings((s) => ({ ...s, autoUpdate: e.target.checked }))}
/>
}
label="Автообновление данных (где поддерживается)"
sx={controlSx}
<SettingCheckboxRow
title="Запускать вместе с системой"
description="Лаунчер будет запускаться при старте Windows"
checked={settings.autoLaunch}
onChange={setFlag('autoLaunch')}
/>
<FormControlLabel
control={
<Switch
<SettingCheckboxRow
title="Запускать свернутым (в трей)"
description="Окно не показывается при старте"
checked={settings.startInTray}
onChange={(e) => setSettings((s) => ({ ...s, startInTray: e.target.checked }))}
/>
}
label="Запускать свернутым (в трей)"
sx={controlSx}
onChange={setFlag('startInTray')}
/>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
<SettingCheckboxRow
title="При закрытии сворачивать в трей"
description="Крестик не закрывает приложение полностью"
checked={settings.closeToTray}
onChange={setFlag('closeToTray')}
/>
<Button
onClick={() => {
// просто пример действия
try {
localStorage.removeItem('launcher_cache');
} catch {}
}}
disableRipple
sx={{
borderRadius: '999px',
py: '0.8vw',
fontFamily: 'Benzin-Bold',
color: '#fff',
background: 'rgba(255,255,255,0.10)',
border: '1px solid rgba(255,255,255,0.10)',
'&:hover': { background: 'rgba(255,255,255,0.14)' },
}}
>
Очистить кэш (пример)
</Button>
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.9vw' }}>
Кнопка-заглушка: можно подключить к вашим реальным ключам localStorage.
</Typography>
<SettingCheckboxRow
title="Запоминать последнюю страницу"
description="После перезапуска откроется тот же раздел"
checked={settings.rememberLastRoute}
onChange={setFlag('rememberLastRoute')}
/>
</Box>
</Glass>
</Box>