From 0dfa428931486c0201da0da7f6ad001c3df41d1a Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 20:28:35 +0500 Subject: [PATCH] feat: add admin settings page for display name and avatar editing --- .../pages/admin-layout/ui/AdminLayoutPage.tsx | 5 +- client/src/pages/admin-settings/index.ts | 1 + .../admin-settings/ui/AdminSettingsPage.tsx | 247 ++++++++++++++++++ server/src/routes/api.js | 2 + server/src/routes/api/admin-profile.js | 64 +++++ 5 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 client/src/pages/admin-settings/index.ts create mode 100644 client/src/pages/admin-settings/ui/AdminSettingsPage.tsx create mode 100644 server/src/routes/api/admin-profile.js diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx index 29c1ff5..b7e0e73 100644 --- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx @@ -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: }, { to: '/admin/users', label: 'Пользователи', icon: }, { to: '/admin/notifications', label: 'Уведомления', icon: }, + { to: '/admin/settings', label: 'Настройки', icon: }, ], [], ) @@ -192,6 +194,7 @@ export function AdminLayoutPage() { } /> } /> } /> + } /> } /> diff --git a/client/src/pages/admin-settings/index.ts b/client/src/pages/admin-settings/index.ts new file mode 100644 index 0000000..e5be1b6 --- /dev/null +++ b/client/src/pages/admin-settings/index.ts @@ -0,0 +1 @@ +export { AdminSettingsPage } from './ui/AdminSettingsPage' diff --git a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx new file mode 100644 index 0000000..fff9454 --- /dev/null +++ b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx @@ -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(null) + const [previewStyle, setPreviewStyle] = useState(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 Загрузка настроек… + if (isError) return Не удалось загрузить настройки. + if (!user) return Нужно войти. + + return ( + + + Настройки + + + Текущая почта: {String(user.email)} + + + {profileErrorMsg && ( + + {profileErrorMsg} + + )} + + + + + Профиль + + + + + + + + + + + + Аватар + + + + + + + {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} + + + {hasUnsavedPreview && ( + + + + Текущий + + + )} + + + + + Стиль + + + + + + {hasUnsavedPreview && ( + + + + + )} + + {hasOAuthAvatar && !hasUnsavedPreview && ( + + )} + + + + ) +} diff --git a/server/src/routes/api.js b/server/src/routes/api.js index 32612d6..66a71c3 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -1,5 +1,6 @@ import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js' import { registerAdminNotificationRoutes } from './api/admin/notifications.js' +import { registerAdminProfileRoutes } from './api/admin-profile.js' import { registerAdminCategoryRoutes } from './api/admin-categories.js' import { registerAdminGalleryRoutes } from './api/admin-gallery.js' import { registerAdminOrderRoutes } from './api/admin-orders.js' @@ -26,4 +27,5 @@ export async function registerApiRoutes(fastify) { await registerAdminReviewRoutes(fastify) await registerAdminUserRoutes(fastify) await registerAdminNotificationRoutes(fastify) + await registerAdminProfileRoutes(fastify) } diff --git a/server/src/routes/api/admin-profile.js b/server/src/routes/api/admin-profile.js new file mode 100644 index 0000000..ab7f5c2 --- /dev/null +++ b/server/src/routes/api/admin-profile.js @@ -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, + } + }) +}