feat: add admin settings page for display name and avatar editing
This commit is contained in:
@@ -15,7 +15,7 @@ import Typography from '@mui/material/Typography'
|
|||||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useUnit } from 'effector-react'
|
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 { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
|
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
|
||||||
import { AdminCategoriesPage } from '@/pages/admin-categories'
|
import { AdminCategoriesPage } from '@/pages/admin-categories'
|
||||||
@@ -23,6 +23,7 @@ import { AdminGalleryPage } from '@/pages/admin-gallery'
|
|||||||
import { AdminOrdersPage } from '@/pages/admin-orders'
|
import { AdminOrdersPage } from '@/pages/admin-orders'
|
||||||
import { AdminProductsPage } from '@/pages/admin-products'
|
import { AdminProductsPage } from '@/pages/admin-products'
|
||||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||||
|
import { AdminSettingsPage } from '@/pages/admin-settings'
|
||||||
import { AdminUsersPage } from '@/pages/admin-users'
|
import { AdminUsersPage } from '@/pages/admin-users'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
|
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
|
||||||
@@ -63,6 +64,7 @@ export function AdminLayoutPage() {
|
|||||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
|
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
|
||||||
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
|
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
|
||||||
{ to: '/admin/notifications', label: 'Уведомления', icon: <Bell /> },
|
{ 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="reviews" element={<AdminReviewsPage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<Route path="notifications" element={<AdminNotificationsPage />} />
|
<Route path="notifications" element={<AdminNotificationsPage />} />
|
||||||
|
<Route path="settings" element={<AdminSettingsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js'
|
import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js'
|
||||||
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
|
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
|
||||||
|
import { registerAdminProfileRoutes } from './api/admin-profile.js'
|
||||||
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
||||||
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
||||||
import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
||||||
@@ -26,4 +27,5 @@ export async function registerApiRoutes(fastify) {
|
|||||||
await registerAdminReviewRoutes(fastify)
|
await registerAdminReviewRoutes(fastify)
|
||||||
await registerAdminUserRoutes(fastify)
|
await registerAdminUserRoutes(fastify)
|
||||||
await registerAdminNotificationRoutes(fastify)
|
await registerAdminNotificationRoutes(fastify)
|
||||||
|
await registerAdminProfileRoutes(fastify)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
|
export async function registerAdminProfileRoutes(fastify) {
|
||||||
|
fastify.get('/api/admin/profile', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||||
|
if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatar: user.avatar,
|
||||||
|
avatarType: user.avatarType,
|
||||||
|
avatarStyle: user.avatarStyle,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.patch('/api/admin/profile', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const nameRaw = request.body?.displayName
|
||||||
|
const displayName = nameRaw === null || nameRaw === undefined ? undefined : String(nameRaw).trim()
|
||||||
|
const avatarRaw = request.body?.avatar
|
||||||
|
const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim()
|
||||||
|
const avatarTypeRaw = request.body?.avatarType
|
||||||
|
const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim()
|
||||||
|
const avatarStyleRaw = request.body?.avatarStyle
|
||||||
|
const avatarStyle =
|
||||||
|
avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim()
|
||||||
|
|
||||||
|
if (displayName !== undefined && displayName !== null && displayName.length > 40)
|
||||||
|
return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||||
|
if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') {
|
||||||
|
return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' })
|
||||||
|
}
|
||||||
|
if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' })
|
||||||
|
if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) {
|
||||||
|
return reply.code(400).send({ error: 'Стиль аватара слишком длинный' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {}
|
||||||
|
if (displayName !== undefined) {
|
||||||
|
data.displayName = displayName && displayName.length ? displayName : null
|
||||||
|
}
|
||||||
|
if (avatarType !== undefined) {
|
||||||
|
data.avatarType = avatarType === '' ? null : avatarType
|
||||||
|
}
|
||||||
|
if (avatar !== undefined) {
|
||||||
|
data.avatar = avatar === '' ? null : avatar
|
||||||
|
}
|
||||||
|
if (avatarStyle !== undefined) {
|
||||||
|
data.avatarStyle = avatarStyle === '' ? null : avatarStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({ where: { id: userId }, data })
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
email: updated.email,
|
||||||
|
displayName: updated.displayName,
|
||||||
|
avatar: updated.avatar,
|
||||||
|
avatarType: updated.avatarType,
|
||||||
|
avatarStyle: updated.avatarStyle,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user