574 lines
18 KiB
TypeScript
574 lines
18 KiB
TypeScript
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 mapNotifPosition = (
|
||
p: SettingsState['notificationPosition'],
|
||
): NotificationPosition => {
|
||
const [vertical, horizontal] = p.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right'];
|
||
return { vertical, horizontal };
|
||
};
|
||
|
||
const Settings = () => {
|
||
const [lastSavedSettings, setLastSavedSettings] = useState<SettingsState>(() => {
|
||
if (typeof window === 'undefined') return defaultSettings;
|
||
return safeParseSettings(localStorage.getItem(STORAGE_KEY)) ?? defaultSettings;
|
||
});
|
||
const [notifOpen, setNotifOpen] = useState(false);
|
||
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||
const [notifSeverity, setNotifSeverity] = useState<
|
||
'success' | 'info' | 'warning' | 'error'
|
||
>('info');
|
||
|
||
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||
vertical: 'bottom',
|
||
horizontal: 'center',
|
||
});
|
||
|
||
const [settings, setSettings] = useState<SettingsState>(() => {
|
||
if (typeof window === 'undefined') return defaultSettings;
|
||
return safeParseSettings(localStorage.getItem(STORAGE_KEY)) ?? defaultSettings;
|
||
});
|
||
|
||
const dirty = useMemo(() => {
|
||
return JSON.stringify(settings) !== JSON.stringify(lastSavedSettings);
|
||
}, [settings, lastSavedSettings]);
|
||
|
||
const save = () => {
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||
|
||
setLastSavedSettings(settings);
|
||
|
||
window.dispatchEvent(new CustomEvent('settings-updated'));
|
||
|
||
// если уведомления выключены — НЕ показываем нотификацию
|
||
if (!isNotificationsEnabled()) return;
|
||
setNotifMsg('Настройки успешно сохранены!');
|
||
setNotifSeverity('info');
|
||
setNotifPos(mapNotifPosition(settings.notificationPosition));
|
||
setNotifOpen(true);
|
||
} catch (e) {
|
||
console.error('Не удалось сохранить настройки', e);
|
||
}
|
||
};
|
||
|
||
const reset = () => {
|
||
setSettings(defaultSettings);
|
||
setLastSavedSettings(defaultSettings);
|
||
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultSettings));
|
||
} catch (e) {
|
||
console.error('Не удалось сбросить настройки', e);
|
||
}
|
||
};
|
||
|
||
const checkNotif = () => {
|
||
if (!settings.notifications) return; // если выключены — не показываем
|
||
|
||
setNotifMsg('Проверка уведомления!');
|
||
setNotifSeverity('info');
|
||
setNotifPos(mapNotifPosition(settings.notificationPosition)); // 👈 важно
|
||
setNotifOpen(true);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (typeof document === 'undefined') return;
|
||
|
||
const scale = settings.uiScale / 100;
|
||
document.documentElement.style.setProperty('--ui-scale', String(scale));
|
||
document.body.classList.toggle('reduce-motion', settings.reduceMotion);
|
||
document.body.classList.toggle('no-blur', !settings.blurEffects);
|
||
}, [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;
|