feat: add admin settings page for display name and avatar editing

This commit is contained in:
Kirill
2026-05-21 20:28:35 +05:00
parent 37be5eef08
commit 0dfa428931
5 changed files with 318 additions and 1 deletions
@@ -15,7 +15,7 @@ import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Bell, Image, LayoutGrid, ListOrdered, MessageSquare, Store, Users } from 'lucide-react'
import { Bell, Image, LayoutGrid, ListOrdered, MessageSquare, Settings, Store, Users } from 'lucide-react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
import { AdminCategoriesPage } from '@/pages/admin-categories'
@@ -23,6 +23,7 @@ import { AdminGalleryPage } from '@/pages/admin-gallery'
import { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminProductsPage } from '@/pages/admin-products'
import { AdminReviewsPage } from '@/pages/admin-reviews'
import { AdminSettingsPage } from '@/pages/admin-settings'
import { AdminUsersPage } from '@/pages/admin-users'
import { $user } from '@/shared/model/auth'
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
@@ -63,6 +64,7 @@ export function AdminLayoutPage() {
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
{ to: '/admin/notifications', label: 'Уведомления', icon: <Bell /> },
{ to: '/admin/settings', label: 'Настройки', icon: <Settings /> },
],
[],
)
@@ -192,6 +194,7 @@ export function AdminLayoutPage() {
<Route path="reviews" element={<AdminReviewsPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="notifications" element={<AdminNotificationsPage />} />
<Route path="settings" element={<AdminSettingsPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
</Box>
+1
View File
@@ -0,0 +1 @@
export { AdminSettingsPage } from './ui/AdminSettingsPage'
@@ -0,0 +1,247 @@
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_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles'
import { $user, UpdateProfileParams, updateProfileFx } 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
avatarType: 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 hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated')
const useOAuth = user?.avatarType === 'oauth'
const useGenerated = user?.avatarType === 'generated'
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
avatarType?: string | null
avatarStyle?: string | null
}) => apiClient.patch('admin/profile', params),
onSuccess: () => {
const name = profileForm.getValues('displayName').trim()
const p: UpdateProfileParams = { displayName: name.length ? name : null }
if (hasUnsavedPreview) {
p.avatar = previewSrc
p.avatarType = 'generated'
p.avatarStyle = previewStyle
}
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}
avatarType={hasUnsavedPreview ? 'generated' : user.avatarType}
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 ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'}
</Typography>
</Box>
{hasUnsavedPreview && (
<Box sx={{ textAlign: 'center' }}>
<UserAvatar
userId={String(user.id)}
avatarUrl={user.avatar}
avatarType={user.avatarType}
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_STYLES.map((s) => (
<MenuItem key={s.id} value={s.id}>
{s.label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="outlined"
onClick={() => {
const seed = `${String(user.id)}_${Date.now()}`
const styleDef = getStyleById(selectedStyle)
const avatar = createAvatar(styleDef.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,
avatarType: 'generated',
avatarStyle: previewStyle,
})
setPreviewSrc(null)
}}
>
Сохранить
</Button>
<Button variant="text" onClick={() => setPreviewSrc(null)}>
Отмена
</Button>
</Stack>
)}
{hasOAuthAvatar && !hasUnsavedPreview && (
<Button
variant="outlined"
disabled={pendingProfile || profileSaveMut.isPending || useOAuth}
onClick={() => {
const raw = profileForm.getValues('displayName')
const name = raw.trim()
profileSaveMut.mutate({
displayName: name.length ? name : null,
avatarType: 'oauth',
})
}}
sx={{ mt: 0.5 }}
>
Использовать OAuth
</Button>
)}
</Box>
</Stack>
</Box>
)
}