217 lines
7.8 KiB
TypeScript
217 lines
7.8 KiB
TypeScript
import { useState } from 'react'
|
||
import Alert from '@mui/material/Alert'
|
||
import Box from '@mui/material/Box'
|
||
import Button from '@mui/material/Button'
|
||
import Divider from '@mui/material/Divider'
|
||
import FormControl from '@mui/material/FormControl'
|
||
import InputLabel from '@mui/material/InputLabel'
|
||
import MenuItem from '@mui/material/MenuItem'
|
||
import Select from '@mui/material/Select'
|
||
import Stack from '@mui/material/Stack'
|
||
import TextField from '@mui/material/TextField'
|
||
import Typography from '@mui/material/Typography'
|
||
import { createAvatar } from '@dicebear/core'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { useUnit } from 'effector-react'
|
||
import { useForm } from 'react-hook-form'
|
||
import { apiClient } from '@/shared/api/client'
|
||
import { AVATAR_STYLE_LOADERS, DEFAULT_STYLE_ID, loadAvatarStyle } from '@/shared/lib/avatar-styles'
|
||
import { $user, updateProfileFx } from '@/shared/model/auth'
|
||
import type { UpdateProfileParams } from '@/shared/model/auth'
|
||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||
|
||
function getApiErrorMessage(error: unknown): string | null {
|
||
const e = error as { response?: { data?: { error?: string } } }
|
||
const msg = e?.response?.data?.error
|
||
return msg ? String(msg) : null
|
||
}
|
||
|
||
export function AdminSettingsPage() {
|
||
const user = useUnit($user)
|
||
const qc = useQueryClient()
|
||
const pendingProfile = useUnit(updateProfileFx.pending)
|
||
|
||
const {
|
||
data: profile,
|
||
isLoading,
|
||
isError,
|
||
} = useQuery({
|
||
queryKey: ['admin', 'profile'],
|
||
queryFn: async () => {
|
||
const { data } = await apiClient.get<{
|
||
id: string
|
||
email: string
|
||
displayName: string | null
|
||
avatar: string | null
|
||
avatarStyle: string | null
|
||
}>('admin/profile')
|
||
return data
|
||
},
|
||
})
|
||
|
||
const profileForm = useForm<{ displayName: string }>({
|
||
defaultValues: { displayName: profile?.displayName ?? '' },
|
||
values: { displayName: profile?.displayName ?? '' },
|
||
mode: 'onChange',
|
||
})
|
||
|
||
const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID)
|
||
const [previewSrc, setPreviewSrc] = useState<string | null>(null)
|
||
const [previewStyle, setPreviewStyle] = useState<string>(DEFAULT_STYLE_ID)
|
||
|
||
const hasUnsavedPreview = previewSrc !== null
|
||
|
||
const profileSaveMut = useMutation({
|
||
mutationFn: (params: { displayName: string | null; avatar?: string | null; avatarStyle?: string | null }) =>
|
||
apiClient.patch('admin/profile', params),
|
||
onSuccess: (_data, variables) => {
|
||
const p: UpdateProfileParams = { displayName: variables.displayName ?? null }
|
||
if (variables.avatar !== undefined) {
|
||
p.avatar = variables.avatar
|
||
p.avatarStyle = variables.avatarStyle ?? null
|
||
}
|
||
updateProfileFx(p)
|
||
void qc.invalidateQueries({ queryKey: ['admin', 'profile'] })
|
||
},
|
||
})
|
||
|
||
const profileErrorMsg = getApiErrorMessage(profileSaveMut.error)
|
||
|
||
if (isLoading) return <Typography>Загрузка настроек…</Typography>
|
||
if (isError) return <Alert severity="error">Не удалось загрузить настройки.</Alert>
|
||
if (!user) return <Alert severity="info">Нужно войти.</Alert>
|
||
|
||
return (
|
||
<Box>
|
||
<Typography variant="h4" gutterBottom>
|
||
Настройки
|
||
</Typography>
|
||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||
Текущая почта: <b>{String(user.email)}</b>
|
||
</Typography>
|
||
|
||
{profileErrorMsg && (
|
||
<Alert severity="error" sx={{ mb: 2 }}>
|
||
{profileErrorMsg}
|
||
</Alert>
|
||
)}
|
||
|
||
<Stack spacing={3} sx={{ maxWidth: 560 }}>
|
||
<Box>
|
||
<Typography variant="h6" gutterBottom>
|
||
Профиль
|
||
</Typography>
|
||
<Stack spacing={2}>
|
||
<TextField
|
||
label="Имя или ник"
|
||
helperText="До 40 символов"
|
||
slotProps={{ htmlInput: { maxLength: 40 } }}
|
||
{...profileForm.register('displayName')}
|
||
/>
|
||
<Button
|
||
variant="contained"
|
||
disabled={pendingProfile || profileSaveMut.isPending}
|
||
onClick={() => {
|
||
const raw = profileForm.getValues('displayName')
|
||
const name = raw.trim()
|
||
profileSaveMut.mutate({ displayName: name.length ? name : null })
|
||
}}
|
||
>
|
||
Сохранить
|
||
</Button>
|
||
</Stack>
|
||
</Box>
|
||
|
||
<Divider />
|
||
|
||
<Box>
|
||
<Typography variant="h6" gutterBottom>
|
||
Аватар
|
||
</Typography>
|
||
|
||
<Stack direction="row" spacing={3} sx={{ alignItems: 'flex-start', mb: 2 }}>
|
||
<Box sx={{ textAlign: 'center' }}>
|
||
<UserAvatar
|
||
userId={String(user.id)}
|
||
avatarUrl={hasUnsavedPreview ? previewSrc : user.avatar}
|
||
avatarStyle={hasUnsavedPreview ? previewStyle : user.avatarStyle}
|
||
size={80}
|
||
sx={{
|
||
border: 2,
|
||
borderColor: hasUnsavedPreview ? 'warning.main' : 'primary.main',
|
||
}}
|
||
/>
|
||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||
{hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
|
||
</Typography>
|
||
</Box>
|
||
{hasUnsavedPreview && (
|
||
<Box sx={{ textAlign: 'center' }}>
|
||
<UserAvatar
|
||
userId={String(user.id)}
|
||
avatarUrl={user.avatar}
|
||
avatarStyle={user.avatarStyle}
|
||
size={80}
|
||
sx={{ border: 2, borderColor: 'divider', opacity: 0.6 }}
|
||
/>
|
||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||
Текущий
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
</Stack>
|
||
|
||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||
<InputLabel>Стиль</InputLabel>
|
||
<Select value={selectedStyle} label="Стиль" onChange={(e) => setSelectedStyle(e.target.value)}>
|
||
{AVATAR_STYLE_LOADERS.map((s) => (
|
||
<MenuItem key={s.id} value={s.id}>
|
||
{s.label}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
<Button
|
||
variant="outlined"
|
||
onClick={async () => {
|
||
const seed = `${String(user.id)}_${Date.now()}`
|
||
const style = await loadAvatarStyle(selectedStyle)
|
||
const avatar = createAvatar(style, { seed })
|
||
setPreviewSrc(avatar.toDataUri())
|
||
setPreviewStyle(selectedStyle)
|
||
}}
|
||
>
|
||
Сгенерировать
|
||
</Button>
|
||
</Stack>
|
||
|
||
{hasUnsavedPreview && (
|
||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
||
<Button
|
||
variant="contained"
|
||
disabled={pendingProfile || profileSaveMut.isPending}
|
||
onClick={() => {
|
||
const raw = profileForm.getValues('displayName')
|
||
const name = raw.trim()
|
||
profileSaveMut.mutate({
|
||
displayName: name.length ? name : null,
|
||
avatar: previewSrc,
|
||
avatarStyle: previewStyle,
|
||
})
|
||
setPreviewSrc(null)
|
||
}}
|
||
>
|
||
Сохранить
|
||
</Button>
|
||
<Button variant="text" onClick={() => setPreviewSrc(null)}>
|
||
Отмена
|
||
</Button>
|
||
</Stack>
|
||
)}
|
||
</Box>
|
||
</Stack>
|
||
</Box>
|
||
)
|
||
}
|