Добавлена логика загрузки аватаров пользователей через API с использованием S3. Реализованы проверки на тип и размер файла, а также обработка ошибок. Обновлены компоненты UserSettings и useAuth для интеграции новой функциональности. Оптимизирована работа с кэшем Apollo Client для мгновенного обновления аватара в интерфейсе.

This commit is contained in:
Bivekich
2025-07-28 09:08:38 +03:00
parent ec28803549
commit 5c57c34c1a
5 changed files with 266 additions and 24 deletions

View File

@ -1,7 +1,119 @@
import { NextRequest, NextResponse } from 'next/server' 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) { 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) { export async function DELETE(request: NextRequest) {

View File

@ -3,6 +3,8 @@
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
import { useMutation } from '@apollo/client' import { useMutation } from '@apollo/client'
import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN } from '@/graphql/mutations' 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 { formatPhone } from '@/lib/utils'
import S3Service from '@/services/s3-service' import S3Service from '@/services/s3-service'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
@ -35,12 +37,12 @@ import {
Settings, Settings,
Camera Camera
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import Image from 'next/image' import Image from 'next/image'
export function UserSettings() { export function UserSettings() {
const { getSidebarMargin } = useSidebar() const { getSidebarMargin } = useSidebar()
const { user } = useAuth() const { user, updateUser } = useAuth()
const [updateUserProfile, { loading: isSaving }] = useMutation(UPDATE_USER_PROFILE) const [updateUserProfile, { loading: isSaving }] = useMutation(UPDATE_USER_PROFILE)
const [updateOrganizationByInn, { loading: isUpdatingOrganization }] = useMutation(UPDATE_ORGANIZATION_BY_INN) const [updateOrganizationByInn, { loading: isUpdatingOrganization }] = useMutation(UPDATE_ORGANIZATION_BY_INN)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
@ -48,6 +50,9 @@ export function UserSettings() {
const [partnerLink, setPartnerLink] = useState('') const [partnerLink, setPartnerLink] = useState('')
const [isGenerating, setIsGenerating] = useState(false) const [isGenerating, setIsGenerating] = useState(false)
const [isUploadingAvatar, setIsUploadingAvatar] = 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({ const [formData, setFormData] = useState({
@ -127,10 +132,10 @@ export function UserSettings() {
} }
setFormData({ setFormData({
orgPhone: orgPhone, orgPhone: orgPhone || '+7',
managerName: user?.managerName || '', managerName: user?.managerName || '',
telegram: customContacts?.telegram || '', telegram: customContacts?.telegram || '',
whatsapp: customContacts?.whatsapp || '', whatsapp: customContacts?.whatsapp || '+7',
email: email, email: email,
orgName: org.name || '', orgName: org.name || '',
address: org.address || '', address: org.address || '',
@ -291,27 +296,73 @@ export function UserSettings() {
try { try {
const avatarUrl = await S3Service.uploadAvatar(file, user.id) const avatarUrl = await S3Service.uploadAvatar(file, user.id)
// Сразу обновляем локальное состояние для мгновенного отображения
setLocalAvatarUrl(avatarUrl)
// Обновляем аватар пользователя через GraphQL // Обновляем аватар пользователя через GraphQL
const result = await updateUserProfile({ const result = await updateUserProfile({
variables: { variables: {
input: { input: {
avatar: avatarUrl 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) { 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(() => { setTimeout(() => {
window.location.reload() setSaveMessage(null)
}, 1000) }, 3000)
} else { } else {
throw new Error('Failed to update avatar') throw new Error(result.data?.updateUserProfile?.message || 'Failed to update avatar')
} }
} catch (error) { } catch (error) {
console.error('Error uploading avatar:', 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 { } finally {
setIsUploadingAvatar(false) setIsUploadingAvatar(false)
} }
@ -324,14 +375,69 @@ export function UserSettings() {
} }
const formatPhoneInput = (value: string) => { 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 <= 4) return `+7 (${cleaned.slice(1)}`
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}` 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 <= 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)}` 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) => { const formatTelegram = (value: string) => {
// Убираем все символы кроме букв, цифр, _ и @ // Убираем все символы кроме букв, цифр, _ и @
let cleaned = value.replace(/[^a-zA-Z0-9_@]/g, '') 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="flex items-center gap-4 mb-6">
<div className="relative"> <div className="relative">
<Avatar className="h-16 w-16"> <Avatar className="h-16 w-16">
{user?.avatar ? ( {(localAvatarUrl || user?.avatar) ? (
<Image <Image
src={user.avatar} src={localAvatarUrl || user.avatar}
alt="Аватар" alt="Аватар"
width={64} width={64}
height={64} height={64}
@ -724,8 +830,16 @@ export function UserSettings() {
<div> <div>
<Label className="text-white/80 text-sm mb-2 block">Номер телефона организации</Label> <Label className="text-white/80 text-sm mb-2 block">Номер телефона организации</Label>
<Input <Input
ref={phoneInputRef}
value={formData.orgPhone || ''} 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" placeholder="+7 (999) 999-99-99"
readOnly={!isEditing} readOnly={!isEditing}
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${ className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
@ -789,8 +903,16 @@ export function UserSettings() {
WhatsApp WhatsApp
</Label> </Label>
<Input <Input
ref={whatsappInputRef}
value={formData.whatsapp || ''} 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" placeholder="+7 (999) 999-99-99"
readOnly={!isEditing} readOnly={!isEditing}
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${ className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${

View File

@ -201,6 +201,8 @@ export const UPDATE_USER_PROFILE = gql`
user { user {
id id
phone phone
avatar
managerName
organization { organization {
id id
inn inn

View File

@ -85,6 +85,7 @@ interface UseAuthReturn {
isAuthenticated: boolean isAuthenticated: boolean
isLoading: boolean isLoading: boolean
checkAuth: () => Promise<void> checkAuth: () => Promise<void>
updateUser: (updatedUser: Partial<User>) => void
logout: () => void logout: () => void
} }
@ -99,6 +100,14 @@ export const useAuth = (): UseAuthReturn => {
const [isCheckingAuth, setIsCheckingAuth] = useState(false) // Защита от повторных вызовов const [isCheckingAuth, setIsCheckingAuth] = useState(false) // Защита от повторных вызовов
const { refreshApolloClient } = useApolloRefresh() const { refreshApolloClient } = useApolloRefresh()
// Функция для обновления данных пользователя
const updateUser = (updatedUser: Partial<User>) => {
setUser(currentUser => {
if (!currentUser) return currentUser
return { ...currentUser, ...updatedUser }
})
}
const [sendSmsCodeMutation] = useMutation(SEND_SMS_CODE) const [sendSmsCodeMutation] = useMutation(SEND_SMS_CODE)
const [verifySmsCodeMutation] = useMutation(VERIFY_SMS_CODE) const [verifySmsCodeMutation] = useMutation(VERIFY_SMS_CODE)
const [registerFulfillmentMutation] = useMutation(REGISTER_FULFILLMENT_ORGANIZATION) const [registerFulfillmentMutation] = useMutation(REGISTER_FULFILLMENT_ORGANIZATION)
@ -363,6 +372,7 @@ export const useAuth = (): UseAuthReturn => {
isAuthenticated, isAuthenticated,
isLoading, isLoading,
checkAuth, checkAuth,
updateUser,
logout logout
} }
} }

View File

@ -27,25 +27,21 @@ export class S3Service {
} }
static async uploadAvatar(file: File, userId: string): Promise<string> { static async uploadAvatar(file: File, userId: string): Promise<string> {
const fileName = `${userId}-${Date.now()}.${file.name.split('.').pop()}`
const key = `avatars/${fileName}`
try { try {
// Создаем FormData для загрузки // Создаем FormData для загрузки
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
formData.append('key', key) formData.append('userId', userId)
formData.append('bucket', s3Config.bucket)
// Пока используем простую загрузку через наш API // Загружаем через наш API роут
// Позже можно будет сделать прямую загрузку в S3
const response = await fetch('/api/upload-avatar', { const response = await fetch('/api/upload-avatar', {
method: 'POST', method: 'POST',
body: formData body: formData
}) })
if (!response.ok) { 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() const result = await response.json()