Files
holy-flavor-rater/frontend/src/pages/Settings.tsx
T
2026-06-15 20:14:34 +02:00

507 lines
18 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { useMe } from '../api/queries/useMe';
import { useBannersList } from '../api/queries/useBanners';
import {
useChangePassword,
useConfirmAccountDeletion,
useConfirmEmail,
useRequestAccountDeletion,
useUpdateAvatar,
useUpdatePreferences,
useUpdateProfile,
} from '../api/mutations/useSettingsMutations';
import {
Typography,
Box,
TextField,
Button,
Alert,
Avatar,
FormControl,
InputLabel,
Select,
MenuItem,
ListSubheader,
Divider,
Grid,
FormControlLabel,
Switch,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../hooks/useAuth';
import { useTitle } from '../hooks/useTitle';
import type { CatppuccinTheme } from '../theme';
import { PageShell, GlassCard, FormCard } from '../components/ui';
import { useConfirm } from '../hooks/useConfirm';
import { useDrawerAnchor } from '../hooks/useDrawerAnchor';
interface SettingsProps {
themeName: CatppuccinTheme;
onThemeChange: (newTheme: CatppuccinTheme) => void;
}
const readErr = (err: unknown): string | undefined =>
(err as { response?: { data?: { error?: string } } }).response?.data?.error;
const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { refetchUser } = useAuth();
useTitle(t('nav.settings'));
const { confirm } = useConfirm();
const { anchor: drawerAnchor, setAnchor: setDrawerAnchor } = useDrawerAnchor();
const { data: me } = useMe();
const { data: banners = [] } = useBannersList();
const updateAvatarMutation = useUpdateAvatar();
const updatePrefs = useUpdatePreferences();
const updateProfileMutation = useUpdateProfile();
const confirmEmailMutation = useConfirmEmail();
const changePasswordMutation = useChangePassword();
const requestDeletion = useRequestAccountDeletion();
const confirmDeletion = useConfirmAccountDeletion();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [currentEmail, setCurrentEmail] = useState('');
const [avatar, setAvatar] = useState<string | null>(null);
const [language, setLanguage] = useState(i18n.language.split('-')[0]);
const [confirmationCode, setConfirmationCode] = useState('');
const [pendingConfirmation, setPendingConfirmation] = useState(false);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [deletionCode, setDeletionCode] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [selectedBannerId, setSelectedBannerId] = useState<number | string>('');
const handleGoBack = () => {
if (window.history.length > 1) navigate(-1);
else navigate('/');
};
useEffect(() => {
if (!me) return;
setUsername(me.username);
setEmail(me.email);
setCurrentEmail(me.email);
setAvatar(me.avatar);
setSelectedBannerId(me.selected_banner || '');
if (me.language) setLanguage(me.language);
}, [me]);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
setMessage({ type: 'error', text: 'File size exceeds 2MB limit' });
return;
}
try {
const res = await updateAvatarMutation.mutateAsync(file);
setAvatar(res.data.avatar);
setMessage({ type: 'success', text: t('settings.avatarSuccess') });
} catch (err) {
setMessage({ type: 'error', text: readErr(err) || 'Failed to upload avatar' });
}
};
const handleLanguageChange = async (newLang: string) => {
setLanguage(newLang);
i18n.changeLanguage(newLang);
try {
await updatePrefs.mutateAsync({ language: newLang });
} catch {
console.error('Failed to update language on server');
}
};
const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await updateProfileMutation.mutateAsync({ username, email });
if (res.data.message) {
setPendingConfirmation(true);
setMessage({ type: 'success', text: `${t('settings.updateSuccess')} ${res.data.message}` });
} else {
setMessage({ type: 'success', text: t('settings.updateSuccess') });
setCurrentEmail(email);
}
} catch (err) {
setMessage({ type: 'error', text: readErr(err) || 'Failed to update profile' });
}
};
const handleConfirmEmail = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await confirmEmailMutation.mutateAsync(confirmationCode);
setMessage({ type: 'success', text: t('settings.updateSuccess') });
setCurrentEmail(res.data.email);
setPendingConfirmation(false);
setConfirmationCode('');
} catch (err) {
setMessage({ type: 'error', text: readErr(err) || 'Confirmation failed' });
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
try {
await changePasswordMutation.mutateAsync({
old_password: oldPassword,
new_password: newPassword,
});
setMessage({ type: 'success', text: t('settings.passwordSuccess') });
setOldPassword('');
setNewPassword('');
} catch (err) {
setMessage({ type: 'error', text: readErr(err) || 'Failed to change password' });
}
};
const handleRequestDeletion = async () => {
try {
await requestDeletion.mutateAsync();
setIsDeleting(true);
setMessage({ type: 'success', text: t('settings.codeSent') });
} catch {
setMessage({ type: 'error', text: 'Failed to request deletion.' });
}
};
const handleConfirmDeletion = async (e: React.FormEvent) => {
e.preventDefault();
const ok = await confirm({
title: 'Delete account',
message: 'Are you absolutely sure? This cannot be undone.',
confirmLabel: 'Delete forever',
danger: true,
});
if (!ok) return;
try {
await confirmDeletion.mutateAsync(deletionCode);
// Account gone: clear AuthContext + wipe every cached query (stale user
// data must not survive), then SPA-navigate home.
queryClient.clear();
await refetchUser();
navigate('/');
} catch {
setMessage({ type: 'error', text: 'Invalid code. Deletion failed.' });
}
};
return (
<PageShell>
<Button
variant="outlined"
onClick={handleGoBack}
startIcon={<ArrowBackIcon />}
sx={{ alignSelf: 'flex-start', borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
>
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Avatar
src={avatar || undefined}
sx={{ width: 80, height: 80, border: '3px solid', borderColor: 'primary.main' }}
>
{!avatar && username.charAt(0).toUpperCase()}
</Avatar>
<Box>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
{username}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('settings.subtitle')}
</Typography>
</Box>
</Box>
{message && (
<Alert severity={message.type} onClose={() => setMessage(null)}>
{message.text}
</Alert>
)}
<Grid container spacing={4}>
{/* Left Column: Visuals, Avatar */}
<Grid size={{ xs: 12, lg: 5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<GlassCard sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
{t('settings.appearanceTitle')}
</Typography>
<FormControl fullWidth margin="normal">
<InputLabel>{t('settings.themeLabel')}</InputLabel>
<Select
value={themeName}
label={t('settings.themeLabel')}
onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)}
>
<ListSubheader>Holy Archive</ListSubheader>
<MenuItem value="holy_light">Holy Light</MenuItem>
<MenuItem value="holy_dark">Holy Dark</MenuItem>
<ListSubheader>Light Themes</ListSubheader>
<MenuItem value="latte">Catppuccin Latte</MenuItem>
<MenuItem value="pink_pastel">Pink Pastel</MenuItem>
<MenuItem value="mint_pastel">Mint Pastel</MenuItem>
<MenuItem value="lavender_pastel">Lavender Pastel</MenuItem>
<MenuItem value="t0p_sai">Scaled and Icy (T0P)</MenuItem>
<Divider />
<ListSubheader>Dark Themes</ListSubheader>
<MenuItem value="mocha">Catppuccin Mocha</MenuItem>
<MenuItem value="frappe">Catppuccin Frappé</MenuItem>
<MenuItem value="macchiato">Catppuccin Macchiato</MenuItem>
<MenuItem value="dracula">Dracula</MenuItem>
<MenuItem value="nord">Nordic Frost</MenuItem>
<MenuItem value="gruvbox">Gruvbox Retro</MenuItem>
<MenuItem value="oceanic">Oceanic Deep</MenuItem>
<MenuItem value="t0p_trench">Trench (T0P)</MenuItem>
<MenuItem value="t0p_blurryface">Blurryface (T0P)</MenuItem>
<MenuItem value="t0p_clancy">Clancy (T0P)</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth margin="normal">
<InputLabel>{t('settings.bannerLabel')}</InputLabel>
<Select
value={selectedBannerId}
label={t('settings.bannerLabel')}
onChange={async (e) => {
const newId = e.target.value;
setSelectedBannerId(newId);
try {
await updatePrefs.mutateAsync({ selected_banner: newId });
} catch {
console.error('Failed to update banner preference');
}
}}
>
<MenuItem value="">{t('settings.bannerDefault')}</MenuItem>
{banners.map((b) => (
<MenuItem key={b.id} value={b.id}>
{b.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth margin="normal">
<InputLabel>{t('settings.langLabel')}</InputLabel>
<Select
value={language}
label={t('settings.langLabel')}
onChange={(e) => handleLanguageChange(e.target.value)}
>
<MenuItem value="en">English</MenuItem>
<MenuItem value="de">Deutsch</MenuItem>
</Select>
</FormControl>
<Divider sx={{ my: 2 }} />
<FormControlLabel
control={
<Switch
checked={drawerAnchor === 'left'}
onChange={(e) => setDrawerAnchor(e.target.checked ? 'left' : 'right')}
/>
}
label={
<Box>
<Typography variant="body1">
{t('settings.leftHandMode', { defaultValue: 'Left-hand mode' })}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('settings.leftHandModeHint', {
defaultValue:
'Mobile drawer slides in from the left (better for left-handed thumb reach).',
})}
</Typography>
</Box>
}
sx={{ alignItems: 'flex-start', mt: 1 }}
/>
</GlassCard>
{/* Avatar: NOT a FormCard — file input lives outside any form */}
<GlassCard sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
{t('settings.avatarTitle')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, my: 2 }}>
<Avatar src={avatar || undefined} sx={{ width: 100, height: 100 }}>
{!avatar && username.charAt(0).toUpperCase()}
</Avatar>
<Box>
<Button variant="outlined" component="label" size="small">
{t('settings.avatarButton')}
<input type="file" hidden accept="image/*" onChange={handleAvatarUpload} />
</Button>
<Typography
variant="caption"
display="block"
sx={{ mt: 1 }}
color="text.secondary"
>
{t('settings.avatarHint')}
</Typography>
</Box>
</Box>
</GlassCard>
</Box>
</Grid>
{/* Right Column: Info / Password / Danger */}
<Grid size={{ xs: 12, lg: 7 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<FormCard
title={t('settings.infoTitle')}
onSubmit={handleUpdateProfile}
actions={
<Button variant="contained" type="submit" sx={{ borderRadius: 2 }}>
{t('settings.updateButton')}
</Button>
}
>
<TextField
fullWidth
label={t('settings.usernameLabel')}
value={username}
onChange={(e) => setUsername(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
fullWidth
label={t('settings.emailLabel')}
value={email}
onChange={(e) => setEmail(e.target.value)}
helperText={email !== currentEmail ? t('settings.emailHint') : ''}
slotProps={{ inputLabel: { shrink: true } }}
/>
</FormCard>
{pendingConfirmation && (
<FormCard
title={t('settings.confirmEmailTitle')}
subtitle={t('settings.confirmEmailHint', { email })}
onSubmit={handleConfirmEmail}
actions={
<Button
variant="contained"
color="secondary"
type="submit"
sx={{ borderRadius: 2 }}
>
{t('settings.confirmButton')}
</Button>
}
>
<TextField
fullWidth
label={t('settings.deletionCodeLabel')}
value={confirmationCode}
onChange={(e) => setConfirmationCode(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</FormCard>
)}
<FormCard
title={t('settings.passwordTitle')}
onSubmit={handleChangePassword}
actions={
<Button variant="contained" color="warning" type="submit" sx={{ borderRadius: 2 }}>
{t('settings.passwordButton')}
</Button>
}
>
<TextField
fullWidth
label={t('settings.oldPasswordLabel')}
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
fullWidth
label={t('settings.newPasswordLabel')}
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</FormCard>
{!isDeleting ? (
<FormCard
title={t('settings.dangerTitle')}
subtitle={t('settings.dangerDesc')}
danger
asForm={false}
actions={
<Button
variant="outlined"
color="error"
onClick={handleRequestDeletion}
sx={{ borderRadius: 2 }}
>
{t('settings.deleteButton')}
</Button>
}
>
<Box />
</FormCard>
) : (
<FormCard
title={t('settings.dangerTitle')}
subtitle={t('settings.deletionCodeHint')}
danger
onSubmit={handleConfirmDeletion}
actions={
<Box sx={{ display: 'flex', gap: 1 }}>
<Button onClick={() => setIsDeleting(false)} sx={{ borderRadius: 2 }}>
{t('settings.cancelButton')}
</Button>
<Button
variant="contained"
color="error"
type="submit"
disabled={!deletionCode}
sx={{ borderRadius: 2 }}
>
{t('settings.confirmDeleteButton')}
</Button>
</Box>
}
>
<TextField
fullWidth
size="small"
label={t('settings.deletionCodeLabel')}
value={deletionCode}
onChange={(e) => setDeletionCode(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</FormCard>
)}
</Box>
</Grid>
</Grid>
</PageShell>
);
};
export default Settings;