ne minor, a ebat fix
This commit is contained in:
568
src/renderer/pages/Settings.tsx
Normal file
568
src/renderer/pages/Settings.tsx
Normal file
@ -0,0 +1,568 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Slider,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Button,
|
||||
Divider,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import CustomNotification from '../components/Notifications/CustomNotification';
|
||||
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||||
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
|
||||
|
||||
type SettingsState = {
|
||||
// UI
|
||||
uiScale: number; // 80..120
|
||||
reduceMotion: boolean;
|
||||
blurEffects: boolean;
|
||||
|
||||
// Launcher / app
|
||||
autoUpdate: boolean;
|
||||
startInTray: boolean;
|
||||
|
||||
// Game
|
||||
autoRotateSkinViewer: boolean;
|
||||
walkingSpeed: number; // 0..1
|
||||
|
||||
// Notifications
|
||||
notifications: boolean;
|
||||
notificationPosition: 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left';
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'launcher_settings';
|
||||
|
||||
const GRADIENT =
|
||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||||
|
||||
const defaultSettings: SettingsState = {
|
||||
uiScale: 100,
|
||||
reduceMotion: false,
|
||||
blurEffects: true,
|
||||
|
||||
autoUpdate: true,
|
||||
startInTray: false,
|
||||
|
||||
autoRotateSkinViewer: true,
|
||||
walkingSpeed: 0.5,
|
||||
|
||||
notifications: true,
|
||||
notificationPosition: 'top-right',
|
||||
};
|
||||
|
||||
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 Settings = () => {
|
||||
const [notifOpen, setNotifOpen] = useState(false);
|
||||
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||||
const [notifSeverity, setNotifSeverity] = useState<
|
||||
'success' | 'info' | 'warning' | 'error'
|
||||
>('info');
|
||||
|
||||
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
});
|
||||
|
||||
const [settings, setSettings] = useState<SettingsState>(() => {
|
||||
if (typeof window === 'undefined') return defaultSettings;
|
||||
return safeParseSettings(localStorage.getItem(STORAGE_KEY)) ?? defaultSettings;
|
||||
});
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const saved = safeParseSettings(localStorage.getItem(STORAGE_KEY)) ?? defaultSettings;
|
||||
return JSON.stringify(saved) !== JSON.stringify(settings);
|
||||
}, [settings]);
|
||||
|
||||
const save = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
|
||||
window.dispatchEvent(new CustomEvent('settings-updated'));
|
||||
|
||||
// если уведомления выключены — НЕ показываем нотификацию
|
||||
if (!isNotificationsEnabled()) return;
|
||||
setNotifMsg('Настройки успешно сохранены!');
|
||||
setNotifSeverity('info');
|
||||
setNotifPos(getNotifPositionFromSettings());
|
||||
setNotifOpen(true);
|
||||
} catch (e) {
|
||||
console.error('Не удалось сохранить настройки', e);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setSettings(defaultSettings);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultSettings));
|
||||
} catch (e) {
|
||||
console.error('Не удалось сбросить настройки', e);
|
||||
}
|
||||
};
|
||||
|
||||
const checkNotif = () => {
|
||||
setNotifMsg('Проверка уведомления!');
|
||||
setNotifSeverity('info');
|
||||
setNotifPos(getNotifPositionFromSettings());
|
||||
setNotifOpen(true);
|
||||
}
|
||||
|
||||
// Apply a few settings instantly
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
// UI scale (простая версия)
|
||||
document.documentElement.style.zoom = `${settings.uiScale}%`;
|
||||
|
||||
// Reduce motion
|
||||
document.body.classList.toggle('reduce-motion', settings.reduceMotion);
|
||||
|
||||
// Blur effects (можно использовать этот класс в sx, если захочешь)
|
||||
document.body.classList.toggle('no-blur', !settings.blurEffects);
|
||||
|
||||
// Persist
|
||||
save();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings.uiScale, 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
|
||||
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)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: '1.8vw' }}>{children}</Box>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
const controlSx = {
|
||||
'& .MuiFormControlLabel-label': {
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'rgba(255,255,255,0.88)',
|
||||
},
|
||||
'& .MuiSwitch-switchBase.Mui-checked': {
|
||||
color: 'rgba(242,113,33,0.95)',
|
||||
},
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
|
||||
backgroundColor: 'rgba(233,64,205,0.55)',
|
||||
},
|
||||
'& .MuiSwitch-track': {
|
||||
backgroundColor: 'rgba(255,255,255,0.20)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
px: '2vw',
|
||||
pb: '2vw',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<CustomNotification
|
||||
open={notifOpen}
|
||||
message={notifMsg}
|
||||
severity={notifSeverity}
|
||||
position={notifPos}
|
||||
onClose={() => setNotifOpen(false)}
|
||||
autoHideDuration={2500}
|
||||
/>
|
||||
{/* header */}
|
||||
<Box
|
||||
sx={{
|
||||
mb: '1.2vw',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1vw',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: '0.8vw', alignItems: 'center' }}>
|
||||
{dirty && (
|
||||
<Chip
|
||||
label="Есть несохранённые изменения"
|
||||
size="small"
|
||||
sx={{
|
||||
height: '1.6rem',
|
||||
borderRadius: '999px',
|
||||
color: 'white',
|
||||
fontWeight: 900,
|
||||
background:
|
||||
'linear-gradient(120deg, rgba(242,113,33,0.24), rgba(233,64,205,0.16), rgba(138,35,135,0.20))',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={reset}
|
||||
disableRipple
|
||||
sx={{
|
||||
borderRadius: '999px',
|
||||
px: '1.2vw',
|
||||
py: '0.6vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.12)' },
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
<Button
|
||||
onClick={save}
|
||||
disableRipple
|
||||
disabled={!dirty}
|
||||
sx={{
|
||||
borderRadius: '999px',
|
||||
px: '1.2vw',
|
||||
py: '0.6vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: '#fff',
|
||||
background: GRADIENT,
|
||||
opacity: dirty ? 1 : 0.5,
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
|
||||
gap: '2vw',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{/* LEFT */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '2vw', minWidth: 0, width: '43vw' }}>
|
||||
<Glass>
|
||||
<SectionTitle>Интерфейс</SectionTitle>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.88)' }}>
|
||||
Масштаб интерфейса: {settings.uiScale}%
|
||||
</Typography>
|
||||
<Slider
|
||||
value={settings.uiScale}
|
||||
min={80}
|
||||
max={120}
|
||||
step={5}
|
||||
onChange={(_, v) => setSettings((s) => ({ ...s, uiScale: v as number }))}
|
||||
sx={{
|
||||
mt: 0.4,
|
||||
'& .MuiSlider-thumb': { boxShadow: '0 10px 22px rgba(0,0,0,0.45)' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.reduceMotion}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({ ...s, reduceMotion: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Уменьшить анимации"
|
||||
sx={controlSx}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.blurEffects}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({ ...s, blurEffects: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Эффекты размытия (blur)"
|
||||
sx={controlSx}
|
||||
/>
|
||||
</Box>
|
||||
</Glass>
|
||||
|
||||
<Glass>
|
||||
<SectionTitle>Уведомления</SectionTitle>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.notifications}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({ ...s, notifications: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Включить уведомления"
|
||||
sx={controlSx}
|
||||
/>
|
||||
|
||||
<NotificationPositionPicker
|
||||
value={settings.notificationPosition}
|
||||
disabled={!settings.notifications}
|
||||
onChange={(pos) =>
|
||||
setSettings((s) => ({
|
||||
...s,
|
||||
notificationPosition: pos,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<Box sx={{display: 'flex', flexWrap: 'wrap'}}>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.9vw' }}>
|
||||
<span onClick={checkNotif} style={{borderBottom: '1px solid #ccc', cursor: 'pointer'}}>Нажмите сюда,</span> чтобы проверить уведомление.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Glass>
|
||||
</Box>
|
||||
|
||||
{/* RIGHT */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '2vw', minWidth: 0 }}>
|
||||
<Glass>
|
||||
<SectionTitle>Игра</SectionTitle>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.autoRotateSkinViewer}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({ ...s, autoRotateSkinViewer: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Автоповорот персонажа в профиле"
|
||||
sx={controlSx}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.88)' }}>
|
||||
Скорость ходьбы в просмотрщике: {settings.walkingSpeed.toFixed(2)}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={settings.walkingSpeed}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={(_, v) => setSettings((s) => ({ ...s, walkingSpeed: v as number }))}
|
||||
sx={{ mt: 0.4 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.9vw' }}>
|
||||
Эти значения можно прокинуть в Profile: autoRotate и walkingSpeed.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Glass>
|
||||
|
||||
<Glass>
|
||||
<SectionTitle>Лаунчер</SectionTitle>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.autoUpdate}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, autoUpdate: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="Автообновление данных (где поддерживается)"
|
||||
sx={controlSx}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.startInTray}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, startInTray: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="Запускать свернутым (в трей)"
|
||||
sx={controlSx}
|
||||
/>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
// просто пример действия
|
||||
try {
|
||||
localStorage.removeItem('launcher_cache');
|
||||
} catch {}
|
||||
}}
|
||||
disableRipple
|
||||
sx={{
|
||||
borderRadius: '999px',
|
||||
py: '0.8vw',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
color: '#fff',
|
||||
background: 'rgba(255,255,255,0.10)',
|
||||
border: '1px solid rgba(255,255,255,0.10)',
|
||||
'&:hover': { background: 'rgba(255,255,255,0.14)' },
|
||||
}}
|
||||
>
|
||||
Очистить кэш (пример)
|
||||
</Button>
|
||||
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.9vw' }}>
|
||||
Кнопка-заглушка: можно подключить к вашим реальным ключам localStorage.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Glass>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
Reference in New Issue
Block a user