Улучшены стили и функциональность компонентов мессенджера, включая поддержку многострочного ввода сообщений и обновление стилей кнопок. Добавлен новый метод скачивания файлов через API для изображений и документов с обработкой ошибок. Оптимизированы компоненты для загрузки файлов и записи голоса, улучшен интерфейс выбора эмодзи.

This commit is contained in:
Bivekich
2025-07-17 13:06:40 +03:00
parent abd0df1fe7
commit e191055682
9 changed files with 412 additions and 78 deletions

View File

@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const fileUrl = searchParams.get('url')
const fileName = searchParams.get('filename')
if (!fileUrl) {
return NextResponse.json(
{ error: 'File URL is required' },
{ status: 400 }
)
}
// Проверяем, что URL принадлежит нашему S3 хранилищу
if (!fileUrl.includes('s3.twcstorage.ru/617774af-sfera/')) {
return NextResponse.json(
{ error: 'Invalid file URL' },
{ status: 400 }
)
}
console.log('🔽 Проксируем скачивание файла:', fileUrl)
// Загружаем файл с S3
const response = await fetch(fileUrl, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; SferaApp/1.0)',
}
})
if (!response.ok) {
console.error('❌ Ошибка загрузки с S3:', response.status, response.statusText)
return NextResponse.json(
{ error: `Failed to fetch file: ${response.status}` },
{ status: response.status }
)
}
// Получаем содержимое файла
const buffer = await response.arrayBuffer()
console.log('✅ Файл успешно загружен с S3, размер:', buffer.byteLength)
// Возвращаем файл с правильными заголовками для скачивания
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/octet-stream', // Принудительное скачивание
'Content-Disposition': `attachment; filename="${fileName || 'file'}"`,
'Content-Length': buffer.byteLength.toString(),
'Cache-Control': 'no-cache',
},
})
} catch (error) {
console.error('❌ Ошибка в download-file API:', error)
return NextResponse.json(
{ error: 'Failed to download file' },
{ status: 500 }
)
}
}

View File

@ -192,14 +192,18 @@
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.15);
transition: all 0.3s ease; transition: all 0.3s ease;
outline: none;
} }
.glass-input:focus { .glass-input:focus,
.glass-input:focus-visible {
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(168, 85, 247, 0.5); border: 1px solid rgba(168, 85, 247, 0.6);
box-shadow: box-shadow:
0 0 0 3px rgba(168, 85, 247, 0.15), 0 0 0 3px rgba(168, 85, 247, 0.2),
0 4px 16px rgba(147, 51, 234, 0.25); 0 4px 20px rgba(147, 51, 234, 0.3),
0 0 20px rgba(168, 85, 247, 0.15);
outline: none;
} }
.glass-button { .glass-button {

View File

@ -360,7 +360,7 @@ export function MessengerChat({ counterparty }: MessengerChatProps) {
)} )}
<div className={`flex ${isCurrentUser ? 'justify-end' : 'justify-start'} mb-3`}> <div className={`flex ${isCurrentUser ? 'justify-end' : 'justify-start'} mb-3`}>
<div className="flex flex-col max-w-xs lg:max-w-md"> <div className="flex flex-col max-w-xs lg:max-w-md break-words">
{/* Имя отправителя */} {/* Имя отправителя */}
{!isCurrentUser && ( {!isCurrentUser && (
<div className="flex items-center space-x-2 mb-1 px-1"> <div className="flex items-center space-x-2 mb-1 px-1">
@ -409,12 +409,12 @@ export function MessengerChat({ counterparty }: MessengerChatProps) {
isCurrentUser={isCurrentUser} isCurrentUser={isCurrentUser}
/> />
) : msg.content ? ( ) : msg.content ? (
<div className={`px-4 py-2 rounded-lg ${ <div className={`px-4 py-2 rounded-lg break-words ${
isCurrentUser isCurrentUser
? 'bg-blue-500/20 text-white border border-blue-500/30' ? 'bg-blue-500/20 text-white border border-blue-500/30'
: 'bg-white/10 text-white border border-white/20' : 'bg-white/10 text-white border border-white/20'
}`}> }`}>
<p className="text-sm leading-relaxed">{msg.content}</p> <p className="text-sm leading-relaxed whitespace-pre-wrap break-words">{msg.content}</p>
</div> </div>
) : null} ) : null}
@ -434,24 +434,37 @@ export function MessengerChat({ counterparty }: MessengerChatProps) {
</div> </div>
{/* Поле ввода сообщения */} {/* Поле ввода сообщения */}
<div className="p-4 border-t border-white/10"> <div className="px-4 py-3 border-t border-white/10">
<div className="flex space-x-2"> <div className="flex items-center space-x-2">
<div className="flex flex-1 space-x-2"> <div className="flex-1">
<Input <textarea
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
placeholder="Введите сообщение..." placeholder="Введите сообщение..."
className="glass-input text-white placeholder:text-white/40 flex-1" className="glass-input text-white placeholder:text-white/40 w-full resize-none overflow-y-auto rounded-lg py-2 px-3"
rows={1}
style={{
height: 'auto',
minHeight: '40px',
maxHeight: '120px'
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
const newHeight = Math.min(Math.max(target.scrollHeight, 40), 120)
target.style.height = newHeight + 'px'
}}
/> />
</div>
<div className="flex items-center gap-2">
<EmojiPickerComponent onEmojiSelect={handleEmojiSelect} /> <EmojiPickerComponent onEmojiSelect={handleEmojiSelect} />
<FileUploader onSendFile={handleSendFile} /> <FileUploader onSendFile={handleSendFile} />
<VoiceRecorder onSendVoice={handleSendVoice} /> <VoiceRecorder onSendVoice={handleSendVoice} />
</div>
<Button <Button
onClick={handleSendMessage} onClick={handleSendMessage}
disabled={!message.trim()} disabled={!message.trim()}
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 cursor-pointer" className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 cursor-pointer h-10 w-10 p-0"
variant="outline" variant="outline"
> >
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
@ -459,5 +472,6 @@ export function MessengerChat({ counterparty }: MessengerChatProps) {
</div> </div>
</div> </div>
</div> </div>
</div>
) )
} }

View File

@ -45,9 +45,8 @@ export function EmojiPickerComponent({ onEmojiSelect }: EmojiPickerComponentProp
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm"
onClick={() => setShowPicker(!showPicker)} onClick={() => setShowPicker(!showPicker)}
className="text-white/60 hover:text-white hover:bg-white/10 p-2" className="text-white/60 hover:text-white hover:bg-white/10 h-10 w-10 p-0"
> >
<Smile className="h-4 w-4" /> <Smile className="h-4 w-4" />
</Button> </Button>

View File

@ -39,13 +39,43 @@ export function FileMessage({ fileUrl, fileName, fileSize, fileType, isCurrentUs
return parts.length > 1 ? parts.pop()?.toUpperCase() : 'FILE' return parts.length > 1 ? parts.pop()?.toUpperCase() : 'FILE'
} }
const handleDownload = () => { const handleDownload = async () => {
console.log('🔽 Начинаем скачивание файла:', fileName, fileUrl)
try {
// Используем наш API endpoint для проксирования скачивания
const downloadUrl = `/api/download-file?url=${encodeURIComponent(fileUrl)}&filename=${encodeURIComponent(fileName)}`
console.log('📡 Скачиваем через API endpoint:', downloadUrl)
// Создаем ссылку для прямого скачивания
const link = document.createElement('a') const link = document.createElement('a')
link.href = fileUrl link.href = downloadUrl
link.download = fileName link.download = fileName || 'file'
link.style.display = 'none'
document.body.appendChild(link) document.body.appendChild(link)
console.log('⬇️ Запускаем скачивание через API...')
link.click() link.click()
// Очистка с задержкой
setTimeout(() => {
try {
document.body.removeChild(link) document.body.removeChild(link)
console.log('✅ Скачивание через API успешно запущено')
} catch (cleanupError) {
console.warn('⚠️ Ошибка очистки:', cleanupError)
}
}, 1000)
} catch (error) {
console.error('❌ Ошибка скачивания через API:', error)
fallbackDownload()
}
function fallbackDownload() {
console.log('🚨 Используем fallback метод - открытие в новой вкладке')
window.open(fileUrl, '_blank')
}
} }
return ( return (

View File

@ -167,15 +167,14 @@ export function FileUploader({ onSendFile }: FileUploaderProps) {
} }
return ( return (
<div className="flex items-center space-x-2"> <>
{/* Кнопка для загрузки изображений */} {/* Кнопка для загрузки изображений */}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm"
onClick={() => imageInputRef.current?.click()} onClick={() => imageInputRef.current?.click()}
disabled={isUploading} disabled={isUploading}
className="text-white/60 hover:text-white hover:bg-white/10 p-2" className="text-white/60 hover:text-white hover:bg-white/10 h-10 w-10 p-0"
> >
<Image className="h-4 w-4" /> <Image className="h-4 w-4" />
</Button> </Button>
@ -184,10 +183,9 @@ export function FileUploader({ onSendFile }: FileUploaderProps) {
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={isUploading} disabled={isUploading}
className="text-white/60 hover:text-white hover:bg-white/10 p-2" className="text-white/60 hover:text-white hover:bg-white/10 h-10 w-10 p-0"
> >
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
</Button> </Button>
@ -214,6 +212,6 @@ export function FileUploader({ onSendFile }: FileUploaderProps) {
Загрузка... Загрузка...
</div> </div>
)} )}
</div> </>
) )
} }

View File

@ -0,0 +1,205 @@
"use client"
import { useState, useEffect } from 'react'
import { X, Download, ZoomIn, ZoomOut } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface ImageLightboxProps {
imageUrl: string
fileName: string
fileSize?: number
isOpen: boolean
onClose: () => void
}
export function ImageLightbox({ imageUrl, fileName, fileSize, isOpen, onClose }: ImageLightboxProps) {
const [zoom, setZoom] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
// Сброс состояния при открытии/закрытии
useEffect(() => {
if (isOpen) {
setZoom(1)
setPosition({ x: 0, y: 0 })
}
}, [isOpen])
// Закрытие по ESC
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose()
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'unset'
}
}, [isOpen, onClose])
const formatFileSize = (bytes?: number) => {
if (!bytes) return ''
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const handleDownload = async () => {
console.log('🔽 Скачиваем изображение из lightbox:', fileName)
try {
const downloadUrl = `/api/download-file?url=${encodeURIComponent(imageUrl)}&filename=${encodeURIComponent(fileName)}`
const link = document.createElement('a')
link.href = downloadUrl
link.download = fileName || 'image'
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
console.log('✅ Скачивание запущено из lightbox')
} catch (error) {
console.error('❌ Ошибка скачивания из lightbox:', error)
window.open(imageUrl, '_blank')
}
}
const handleZoomIn = () => {
setZoom(prev => Math.min(prev * 1.5, 5))
}
const handleZoomOut = () => {
setZoom(prev => Math.max(prev / 1.5, 0.5))
}
const handleMouseDown = (e: React.MouseEvent) => {
if (zoom > 1) {
setIsDragging(true)
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y
})
}
}
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging && zoom > 1) {
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y
})
}
}
const handleMouseUp = () => {
setIsDragging(false)
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center">
{/* Фон для закрытия */}
<div
className="absolute inset-0"
onClick={onClose}
/>
{/* Панель управления */}
<div className="absolute top-4 right-4 flex items-center space-x-2 z-10">
<Button
onClick={handleZoomOut}
variant="outline"
size="icon"
className="bg-black/50 border-white/20 text-white hover:bg-white/10"
>
<ZoomOut className="h-4 w-4" />
</Button>
<Button
onClick={handleZoomIn}
variant="outline"
size="icon"
className="bg-black/50 border-white/20 text-white hover:bg-white/10"
>
<ZoomIn className="h-4 w-4" />
</Button>
<Button
onClick={handleDownload}
variant="outline"
size="icon"
className="bg-black/50 border-white/20 text-white hover:bg-white/10"
>
<Download className="h-4 w-4" />
</Button>
<Button
onClick={onClose}
variant="outline"
size="icon"
className="bg-black/50 border-white/20 text-white hover:bg-white/10"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Информация о файле */}
<div className="absolute top-4 left-4 z-10">
<div className="bg-black/50 backdrop-blur rounded-lg px-3 py-2">
<p className="text-white font-medium text-sm">{fileName}</p>
{fileSize && (
<p className="text-white/60 text-xs">{formatFileSize(fileSize)}</p>
)}
<p className="text-white/60 text-xs">Zoom: {Math.round(zoom * 100)}%</p>
</div>
</div>
{/* Изображение */}
<div className="relative max-w-full max-h-full overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt={fileName}
className={`max-w-full max-h-full object-contain transition-transform ${
zoom > 1 ? 'cursor-move' : 'cursor-zoom-in'
}`}
style={{
transform: `scale(${zoom}) translate(${position.x / zoom}px, ${position.y / zoom}px)`,
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onClick={(e) => {
e.stopPropagation()
if (zoom === 1) {
handleZoomIn()
}
}}
draggable={false}
/>
</div>
{/* Инструкция */}
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 z-10">
<div className="bg-black/50 backdrop-blur rounded-lg px-4 py-2">
<p className="text-white/80 text-xs text-center">
ESC - закрыть Клик - увеличить Перетаскивание при зуме
</p>
</div>
</div>
</div>
)
}

View File

@ -2,8 +2,9 @@
import { useState } from 'react' import { useState } from 'react'
import Image from 'next/image' import Image from 'next/image'
import { Download, Eye } from 'lucide-react' import { Download, Maximize2 } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ImageLightbox } from '@/components/ui/image-lightbox'
interface ImageMessageProps { interface ImageMessageProps {
imageUrl: string imageUrl: string
@ -14,7 +15,7 @@ interface ImageMessageProps {
export function ImageMessage({ imageUrl, fileName, fileSize, isCurrentUser = false }: ImageMessageProps) { export function ImageMessage({ imageUrl, fileName, fileSize, isCurrentUser = false }: ImageMessageProps) {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [showFullSize, setShowFullSize] = useState(false) const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const formatFileSize = (bytes?: number) => { const formatFileSize = (bytes?: number) => {
if (!bytes) return '' if (!bytes) return ''
@ -24,17 +25,52 @@ export function ImageMessage({ imageUrl, fileName, fileSize, isCurrentUser = fal
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
} }
const handleDownload = () => { const handleDownload = async (e: React.MouseEvent) => {
e.stopPropagation()
console.log('🔽 Начинаем скачивание изображения:', fileName, imageUrl)
try {
// Используем наш API endpoint для проксирования скачивания
const downloadUrl = `/api/download-file?url=${encodeURIComponent(imageUrl)}&filename=${encodeURIComponent(fileName)}`
console.log('📡 Скачиваем через API endpoint:', downloadUrl)
// Создаем ссылку для прямого скачивания
const link = document.createElement('a') const link = document.createElement('a')
link.href = imageUrl link.href = downloadUrl
link.download = fileName link.download = fileName || 'image'
link.style.display = 'none'
document.body.appendChild(link) document.body.appendChild(link)
console.log('⬇️ Запускаем скачивание через API...')
link.click() link.click()
// Очистка с задержкой
setTimeout(() => {
try {
document.body.removeChild(link) document.body.removeChild(link)
console.log('✅ Скачивание изображения через API успешно запущено')
} catch (cleanupError) {
console.warn('⚠️ Ошибка очистки:', cleanupError)
}
}, 1000)
} catch (error) {
console.error('❌ Ошибка скачивания через API:', error)
fallbackDownload()
} }
const handleImageClick = () => { function fallbackDownload() {
setShowFullSize(true) console.log('🚨 Используем fallback метод - открытие в новой вкладке')
window.open(imageUrl, '_blank')
}
}
const handleOpenLightbox = () => {
setIsLightboxOpen(true)
}
const handleCloseLightbox = () => {
setIsLightboxOpen(false)
} }
return ( return (
@ -57,7 +93,7 @@ export function ImageMessage({ imageUrl, fileName, fileSize, isCurrentUser = fal
objectFit: 'cover' objectFit: 'cover'
}} }}
onLoad={() => setIsLoading(false)} onLoad={() => setIsLoading(false)}
onClick={handleImageClick} onClick={handleOpenLightbox}
/> />
{isLoading && ( {isLoading && (
@ -69,18 +105,20 @@ export function ImageMessage({ imageUrl, fileName, fileSize, isCurrentUser = fal
{/* Overlay с кнопками при наведении */} {/* Overlay с кнопками при наведении */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center space-x-2"> <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center space-x-2">
<Button <Button
onClick={handleImageClick} onClick={handleOpenLightbox}
size="sm" size="sm"
variant="ghost" variant="ghost"
className="text-white hover:bg-white/20" className="text-white hover:bg-white/20"
title="Открыть в полном размере"
> >
<Eye className="h-4 w-4" /> <Maximize2 className="h-4 w-4" />
</Button> </Button>
<Button <Button
onClick={handleDownload} onClick={handleDownload}
size="sm" size="sm"
variant="ghost" variant="ghost"
className="text-white hover:bg-white/20" className="text-white hover:bg-white/20"
title="Скачать изображение"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
@ -104,31 +142,14 @@ export function ImageMessage({ imageUrl, fileName, fileSize, isCurrentUser = fal
</div> </div>
</div> </div>
{/* Полноэкранный просмотр */} {/* Lightbox для полноэкранного просмотра */}
{showFullSize && ( <ImageLightbox
<div imageUrl={imageUrl}
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4" fileName={fileName}
onClick={() => setShowFullSize(false)} fileSize={fileSize}
> isOpen={isLightboxOpen}
<div className="relative max-w-full max-h-full"> onClose={handleCloseLightbox}
<Image
src={imageUrl}
alt={fileName}
width={800}
height={600}
className="max-w-full max-h-full object-contain"
onClick={(e) => e.stopPropagation()}
/> />
<Button
onClick={() => setShowFullSize(false)}
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white"
size="sm"
>
×
</Button>
</div>
</div>
)}
</> </>
) )
} }

View File

@ -171,7 +171,7 @@ export function VoiceRecorder({ onSendVoice }: VoiceRecorderProps) {
} }
return ( return (
<div className="flex items-center space-x-2"> <>
{!recordedAudio ? ( {!recordedAudio ? (
// Состояние записи // Состояние записи
<> <>
@ -179,9 +179,8 @@ export function VoiceRecorder({ onSendVoice }: VoiceRecorderProps) {
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm"
onClick={startRecording} onClick={startRecording}
className="text-white/60 hover:text-white hover:bg-white/10 p-2" className="text-white/60 hover:text-white hover:bg-white/10 h-10 w-10 p-0"
> >
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4" />
</Button> </Button>
@ -249,6 +248,6 @@ export function VoiceRecorder({ onSendVoice }: VoiceRecorderProps) {
</Button> </Button>
</div> </div>
)} )}
</div> </>
) )
} }