Files
popa-launcher/src/renderer/pages/Settings.tsx
2025-12-14 22:22:05 +05:00

574 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;