Добавлена логика загрузки аватаров пользователей через API с использованием S3. Реализованы проверки на тип и размер файла, а также обработка ошибок. Обновлены компоненты UserSettings и useAuth для интеграции новой функциональности. Оптимизирована работа с кэшем Apollo Client для мгновенного обновления аватара в интерфейсе.
This commit is contained in:
@ -1,7 +1,119 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region: 'ru-1',
|
||||
endpoint: 'https://s3.twcstorage.ru',
|
||||
credentials: {
|
||||
accessKeyId: 'I6XD2OR7YO2ZN6L6Z629',
|
||||
secretAccessKey: '9xCOoafisG0aB9lJNvdLO1UuK73fBvMcpHMdijrJ'
|
||||
},
|
||||
forcePathStyle: true
|
||||
})
|
||||
|
||||
const BUCKET_NAME = '617774af-sfera'
|
||||
|
||||
// Разрешенные типы изображений для аватарки
|
||||
const ALLOWED_IMAGE_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/webp'
|
||||
]
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ message: 'Upload avatar API' })
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File
|
||||
const userId = formData.get('userId') as string
|
||||
|
||||
if (!file || !userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File and userId are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Проверяем, что файл не пустой
|
||||
if (file.size === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File is empty' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Проверяем имя файла
|
||||
if (!file.name || file.name.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file name' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Проверяем тип файла
|
||||
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `File type ${file.type} is not allowed. Only images are supported.` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Ограничиваем размер файла (2MB для аватарки)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File size must be less than 2MB' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Генерируем уникальное имя файла
|
||||
const timestamp = Date.now()
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase()
|
||||
const fileName = `${userId}-${timestamp}.${fileExtension}`
|
||||
const key = `avatars/${fileName}`
|
||||
|
||||
// Конвертируем файл в Buffer
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
|
||||
// Очищаем метаданные от недопустимых символов
|
||||
const cleanOriginalName = file.name.replace(/[^\w\s.-]/g, '_')
|
||||
const cleanUserId = userId.replace(/[^\w-]/g, '')
|
||||
|
||||
// Загружаем в S3
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: file.type,
|
||||
ACL: 'public-read',
|
||||
Metadata: {
|
||||
originalname: cleanOriginalName,
|
||||
uploadedby: cleanUserId,
|
||||
type: 'avatar'
|
||||
}
|
||||
})
|
||||
|
||||
await s3Client.send(command)
|
||||
|
||||
// Возвращаем URL файла и метаданные
|
||||
const url = `https://s3.twcstorage.ru/${BUCKET_NAME}/${key}`
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url,
|
||||
key,
|
||||
originalName: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading avatar:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to upload avatar' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
|
@ -3,6 +3,8 @@
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN } from '@/graphql/mutations'
|
||||
import { GET_ME } from '@/graphql/queries'
|
||||
import { apolloClient } from '@/lib/apollo-client'
|
||||
import { formatPhone } from '@/lib/utils'
|
||||
import S3Service from '@/services/s3-service'
|
||||
import { Card } from '@/components/ui/card'
|
||||
@ -35,12 +37,12 @@ import {
|
||||
Settings,
|
||||
Camera
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Image from 'next/image'
|
||||
|
||||
export function UserSettings() {
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
const { user } = useAuth()
|
||||
const { user, updateUser } = useAuth()
|
||||
const [updateUserProfile, { loading: isSaving }] = useMutation(UPDATE_USER_PROFILE)
|
||||
const [updateOrganizationByInn, { loading: isUpdatingOrganization }] = useMutation(UPDATE_ORGANIZATION_BY_INN)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
@ -48,6 +50,9 @@ export function UserSettings() {
|
||||
const [partnerLink, setPartnerLink] = useState('')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
||||
const [localAvatarUrl, setLocalAvatarUrl] = useState<string | null>(null)
|
||||
const phoneInputRef = useRef<HTMLInputElement>(null)
|
||||
const whatsappInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Инициализируем данные из пользователя и организации
|
||||
const [formData, setFormData] = useState({
|
||||
@ -127,10 +132,10 @@ export function UserSettings() {
|
||||
}
|
||||
|
||||
setFormData({
|
||||
orgPhone: orgPhone,
|
||||
orgPhone: orgPhone || '+7',
|
||||
managerName: user?.managerName || '',
|
||||
telegram: customContacts?.telegram || '',
|
||||
whatsapp: customContacts?.whatsapp || '',
|
||||
whatsapp: customContacts?.whatsapp || '+7',
|
||||
email: email,
|
||||
orgName: org.name || '',
|
||||
address: org.address || '',
|
||||
@ -291,27 +296,73 @@ export function UserSettings() {
|
||||
try {
|
||||
const avatarUrl = await S3Service.uploadAvatar(file, user.id)
|
||||
|
||||
// Сразу обновляем локальное состояние для мгновенного отображения
|
||||
setLocalAvatarUrl(avatarUrl)
|
||||
|
||||
// Обновляем аватар пользователя через GraphQL
|
||||
const result = await updateUserProfile({
|
||||
variables: {
|
||||
input: {
|
||||
avatar: avatarUrl
|
||||
}
|
||||
},
|
||||
update: (cache, { data }) => {
|
||||
if (data?.updateUserProfile?.success) {
|
||||
// Обновляем кеш Apollo Client
|
||||
try {
|
||||
const existingData: any = cache.readQuery({ query: GET_ME })
|
||||
if (existingData?.me) {
|
||||
cache.writeQuery({
|
||||
query: GET_ME,
|
||||
data: {
|
||||
me: {
|
||||
...existingData.me,
|
||||
avatar: avatarUrl
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Cache update error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (result.data?.updateUserProfile?.success) {
|
||||
setSaveMessage({ type: 'success', text: 'Аватар успешно обновлен! Обновляем страницу...' })
|
||||
setSaveMessage({ type: 'success', text: 'Аватар успешно обновлен!' })
|
||||
|
||||
// Обновляем локальное состояние в useAuth для мгновенного отображения в сайдбаре
|
||||
updateUser({ avatar: avatarUrl })
|
||||
|
||||
// Принудительно обновляем Apollo Client кеш
|
||||
await apolloClient.refetchQueries({
|
||||
include: [GET_ME]
|
||||
})
|
||||
|
||||
// Очищаем input файла
|
||||
if (event.target) {
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
// Очищаем сообщение через 3 секунды
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1000)
|
||||
setSaveMessage(null)
|
||||
}, 3000)
|
||||
} else {
|
||||
throw new Error('Failed to update avatar')
|
||||
throw new Error(result.data?.updateUserProfile?.message || 'Failed to update avatar')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading avatar:', error)
|
||||
setSaveMessage({ type: 'error', text: 'Ошибка при загрузке аватара' })
|
||||
// Сбрасываем локальное состояние при ошибке
|
||||
setLocalAvatarUrl(null)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Ошибка при загрузке аватара'
|
||||
setSaveMessage({ type: 'error', text: errorMessage })
|
||||
// Очищаем сообщение об ошибке через 5 секунд
|
||||
setTimeout(() => {
|
||||
setSaveMessage(null)
|
||||
}, 5000)
|
||||
} finally {
|
||||
setIsUploadingAvatar(false)
|
||||
}
|
||||
@ -324,14 +375,69 @@ export function UserSettings() {
|
||||
}
|
||||
|
||||
const formatPhoneInput = (value: string) => {
|
||||
const cleaned = value.replace(/\D/g, '')
|
||||
if (cleaned.length <= 1) return cleaned
|
||||
// Убираем все нецифровые символы
|
||||
const digitsOnly = value.replace(/\D/g, '')
|
||||
|
||||
// Если строка пустая, возвращаем +7
|
||||
if (!digitsOnly) return '+7'
|
||||
|
||||
// Если пользователь ввел первую цифру не 7, добавляем 7 перед ней
|
||||
let cleaned = digitsOnly
|
||||
if (!cleaned.startsWith('7')) {
|
||||
cleaned = '7' + cleaned
|
||||
}
|
||||
|
||||
// Ограничиваем до 11 цифр (7 + 10 цифр номера)
|
||||
cleaned = cleaned.slice(0, 11)
|
||||
|
||||
// Форматируем в зависимости от длины
|
||||
if (cleaned.length <= 1) return '+7'
|
||||
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
|
||||
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
|
||||
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
|
||||
if (cleaned.length <= 11) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9)}`
|
||||
|
||||
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
|
||||
}
|
||||
|
||||
const handlePhoneInputChange = (field: string, value: string, inputRef?: React.RefObject<HTMLInputElement>) => {
|
||||
const currentInput = inputRef?.current
|
||||
const currentCursorPosition = currentInput?.selectionStart || 0
|
||||
const currentValue = formData[field as keyof typeof formData] as string || ''
|
||||
|
||||
// Если пользователь пытается удалить +7, предотвращаем это
|
||||
if (value.length < 2) {
|
||||
value = '+7'
|
||||
}
|
||||
|
||||
const formatted = formatPhoneInput(value)
|
||||
setFormData(prev => ({ ...prev, [field]: formatted }))
|
||||
|
||||
// Вычисляем новую позицию курсора
|
||||
if (currentInput) {
|
||||
setTimeout(() => {
|
||||
let newCursorPosition = currentCursorPosition
|
||||
|
||||
// Если длина увеличилась (добавили цифру), передвигаем курсор
|
||||
if (formatted.length > currentValue.length) {
|
||||
newCursorPosition = currentCursorPosition + (formatted.length - currentValue.length)
|
||||
}
|
||||
// Если длина уменьшилась (удалили цифру), оставляем курсор на месте или сдвигаем немного
|
||||
else if (formatted.length < currentValue.length) {
|
||||
newCursorPosition = Math.min(currentCursorPosition, formatted.length)
|
||||
}
|
||||
|
||||
// Не позволяем курсору находиться перед +7
|
||||
newCursorPosition = Math.max(newCursorPosition, 2)
|
||||
|
||||
// Ограничиваем курсор длиной строки
|
||||
newCursorPosition = Math.min(newCursorPosition, formatted.length)
|
||||
|
||||
currentInput.setSelectionRange(newCursorPosition, newCursorPosition)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTelegram = (value: string) => {
|
||||
// Убираем все символы кроме букв, цифр, _ и @
|
||||
let cleaned = value.replace(/[^a-zA-Z0-9_@]/g, '')
|
||||
@ -665,9 +771,9 @@ export function UserSettings() {
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="relative">
|
||||
<Avatar className="h-16 w-16">
|
||||
{user?.avatar ? (
|
||||
{(localAvatarUrl || user?.avatar) ? (
|
||||
<Image
|
||||
src={user.avatar}
|
||||
src={localAvatarUrl || user.avatar}
|
||||
alt="Аватар"
|
||||
width={64}
|
||||
height={64}
|
||||
@ -724,8 +830,16 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Номер телефона организации</Label>
|
||||
<Input
|
||||
ref={phoneInputRef}
|
||||
value={formData.orgPhone || ''}
|
||||
onChange={(e) => handleInputChange('orgPhone', e.target.value)}
|
||||
onChange={(e) => handlePhoneInputChange('orgPhone', e.target.value, phoneInputRef)}
|
||||
onKeyDown={(e) => {
|
||||
// Предотвращаем удаление +7
|
||||
if ((e.key === 'Backspace' || e.key === 'Delete') &&
|
||||
phoneInputRef.current?.selectionStart <= 2) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
placeholder="+7 (999) 999-99-99"
|
||||
readOnly={!isEditing}
|
||||
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
|
||||
@ -789,8 +903,16 @@ export function UserSettings() {
|
||||
WhatsApp
|
||||
</Label>
|
||||
<Input
|
||||
ref={whatsappInputRef}
|
||||
value={formData.whatsapp || ''}
|
||||
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
|
||||
onChange={(e) => handlePhoneInputChange('whatsapp', e.target.value, whatsappInputRef)}
|
||||
onKeyDown={(e) => {
|
||||
// Предотвращаем удаление +7
|
||||
if ((e.key === 'Backspace' || e.key === 'Delete') &&
|
||||
whatsappInputRef.current?.selectionStart <= 2) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
placeholder="+7 (999) 999-99-99"
|
||||
readOnly={!isEditing}
|
||||
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
|
||||
|
@ -201,6 +201,8 @@ export const UPDATE_USER_PROFILE = gql`
|
||||
user {
|
||||
id
|
||||
phone
|
||||
avatar
|
||||
managerName
|
||||
organization {
|
||||
id
|
||||
inn
|
||||
|
@ -85,6 +85,7 @@ interface UseAuthReturn {
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
checkAuth: () => Promise<void>
|
||||
updateUser: (updatedUser: Partial<User>) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
@ -99,6 +100,14 @@ export const useAuth = (): UseAuthReturn => {
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(false) // Защита от повторных вызовов
|
||||
const { refreshApolloClient } = useApolloRefresh()
|
||||
|
||||
// Функция для обновления данных пользователя
|
||||
const updateUser = (updatedUser: Partial<User>) => {
|
||||
setUser(currentUser => {
|
||||
if (!currentUser) return currentUser
|
||||
return { ...currentUser, ...updatedUser }
|
||||
})
|
||||
}
|
||||
|
||||
const [sendSmsCodeMutation] = useMutation(SEND_SMS_CODE)
|
||||
const [verifySmsCodeMutation] = useMutation(VERIFY_SMS_CODE)
|
||||
const [registerFulfillmentMutation] = useMutation(REGISTER_FULFILLMENT_ORGANIZATION)
|
||||
@ -363,6 +372,7 @@ export const useAuth = (): UseAuthReturn => {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
checkAuth,
|
||||
updateUser,
|
||||
logout
|
||||
}
|
||||
}
|
@ -27,25 +27,21 @@ export class S3Service {
|
||||
}
|
||||
|
||||
static async uploadAvatar(file: File, userId: string): Promise<string> {
|
||||
const fileName = `${userId}-${Date.now()}.${file.name.split('.').pop()}`
|
||||
const key = `avatars/${fileName}`
|
||||
|
||||
try {
|
||||
// Создаем FormData для загрузки
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('key', key)
|
||||
formData.append('bucket', s3Config.bucket)
|
||||
formData.append('userId', userId)
|
||||
|
||||
// Пока используем простую загрузку через наш API
|
||||
// Позже можно будет сделать прямую загрузку в S3
|
||||
// Загружаем через наш API роут
|
||||
const response = await fetch('/api/upload-avatar', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload avatar')
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to upload avatar')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
Reference in New Issue
Block a user