Добавлены новые зависимости для работы с эмодзи и улучшена структура базы данных. Реализована модель сообщений и обновлены компоненты для поддержки новых функций мессенджера. Обновлены запросы и мутации для работы с сообщениями и чатом.
This commit is contained in:
68
src/components/ui/emoji-picker.tsx
Normal file
68
src/components/ui/emoji-picker.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Smile } from 'lucide-react'
|
||||
|
||||
// Динамически импортируем EmojiPicker чтобы избежать проблем с SSR
|
||||
const EmojiPicker = dynamic(
|
||||
() => import('emoji-picker-react'),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
interface EmojiPickerComponentProps {
|
||||
onEmojiSelect: (emoji: string) => void
|
||||
}
|
||||
|
||||
export function EmojiPickerComponent({ onEmojiSelect }: EmojiPickerComponentProps) {
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
|
||||
setShowPicker(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showPicker) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showPicker])
|
||||
|
||||
const handleEmojiClick = (emojiData: { emoji: string }) => {
|
||||
onEmojiSelect(emojiData.emoji)
|
||||
setShowPicker(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={pickerRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPicker(!showPicker)}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||
>
|
||||
<Smile className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{showPicker && (
|
||||
<div className="absolute bottom-full right-0 mb-2 z-50">
|
||||
<div className="bg-gray-800 border border-white/20 rounded-lg overflow-hidden shadow-xl">
|
||||
<EmojiPicker
|
||||
onEmojiClick={handleEmojiClick}
|
||||
width={350}
|
||||
height={400}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
108
src/components/ui/file-message.tsx
Normal file
108
src/components/ui/file-message.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import { Download, FileText, Archive, Image as ImageIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface FileMessageProps {
|
||||
fileUrl: string
|
||||
fileName: string
|
||||
fileSize?: number
|
||||
fileType?: string
|
||||
isCurrentUser?: boolean
|
||||
}
|
||||
|
||||
export function FileMessage({ fileUrl, fileName, fileSize, fileType, isCurrentUser = false }: FileMessageProps) {
|
||||
|
||||
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 getFileIcon = (type?: string) => {
|
||||
if (!type) return <FileText className="h-6 w-6" />
|
||||
|
||||
if (type.includes('pdf')) return <FileText className="h-6 w-6 text-red-400" />
|
||||
if (type.includes('zip') || type.includes('archive')) return <Archive className="h-6 w-6 text-yellow-400" />
|
||||
if (type.includes('image')) return <ImageIcon className="h-6 w-6 text-green-400" />
|
||||
if (type.includes('word') || type.includes('document')) return <FileText className="h-6 w-6 text-blue-400" />
|
||||
if (type.includes('excel') || type.includes('spreadsheet')) return <FileText className="h-6 w-6 text-green-600" />
|
||||
if (type.includes('powerpoint') || type.includes('presentation')) return <FileText className="h-6 w-6 text-orange-400" />
|
||||
|
||||
return <FileText className="h-6 w-6" />
|
||||
}
|
||||
|
||||
const getFileExtension = (name: string) => {
|
||||
const parts = name.split('.')
|
||||
return parts.length > 1 ? parts.pop()?.toUpperCase() : 'FILE'
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a')
|
||||
link.href = fileUrl
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center space-x-3 p-3 rounded-lg max-w-sm cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
isCurrentUser
|
||||
? 'bg-blue-500/20 border border-blue-500/30'
|
||||
: 'bg-white/10 border border-white/20'
|
||||
}`} onClick={handleDownload}>
|
||||
|
||||
{/* Иконка файла */}
|
||||
<div className={`flex-shrink-0 w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
isCurrentUser ? 'bg-blue-500/30' : 'bg-white/20'
|
||||
}`}>
|
||||
{getFileIcon(fileType)}
|
||||
</div>
|
||||
|
||||
{/* Информация о файле */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium text-sm truncate ${
|
||||
isCurrentUser ? 'text-white' : 'text-white'
|
||||
}`}>
|
||||
{fileName}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
{fileSize && (
|
||||
<span className={`text-xs ${
|
||||
isCurrentUser ? 'text-blue-300/70' : 'text-white/60'
|
||||
}`}>
|
||||
{formatFileSize(fileSize)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={`text-xs ${
|
||||
isCurrentUser ? 'text-blue-300/70' : 'text-white/60'
|
||||
}`}>
|
||||
{getFileExtension(fileName)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка скачивания */}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDownload()
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`flex-shrink-0 ${
|
||||
isCurrentUser
|
||||
? 'text-blue-300 hover:text-blue-200 hover:bg-blue-500/30'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
216
src/components/ui/file-uploader.tsx
Normal file
216
src/components/ui/file-uploader.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Paperclip, Image, X } from 'lucide-react'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
interface FileUploaderProps {
|
||||
onSendFile: (fileUrl: string, fileName: string, fileSize: number, fileType: string, messageType: 'IMAGE' | 'FILE') => void
|
||||
}
|
||||
|
||||
interface UploadedFile {
|
||||
url: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
messageType: 'IMAGE' | 'FILE'
|
||||
}
|
||||
|
||||
export function FileUploader({ onSendFile }: FileUploaderProps) {
|
||||
const { user } = useAuth()
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<UploadedFile | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const isImageType = (type: string) => {
|
||||
return type.startsWith('image/')
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
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(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const handleFileSelect = async (file: File, messageType: 'IMAGE' | 'FILE') => {
|
||||
if (!user?.id) return
|
||||
|
||||
setIsUploading(true)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('userId', user.id)
|
||||
formData.append('messageType', messageType)
|
||||
|
||||
const response = await fetch('/api/upload-file', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to upload file'
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
errorMessage = errorData.error || errorMessage
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Upload failed')
|
||||
}
|
||||
|
||||
setSelectedFile({
|
||||
url: result.url,
|
||||
name: result.originalName,
|
||||
size: result.size,
|
||||
type: result.type,
|
||||
messageType: result.messageType
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка'
|
||||
alert(`Ошибка при загрузке файла: ${errorMessage}`)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>, messageType: 'IMAGE' | 'FILE') => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
handleFileSelect(file, messageType)
|
||||
}
|
||||
// Очищаем input для возможности выбора того же файла
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const handleSendFile = () => {
|
||||
if (selectedFile) {
|
||||
onSendFile(
|
||||
selectedFile.url,
|
||||
selectedFile.name,
|
||||
selectedFile.size,
|
||||
selectedFile.type,
|
||||
selectedFile.messageType
|
||||
)
|
||||
setSelectedFile(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelFile = () => {
|
||||
setSelectedFile(null)
|
||||
}
|
||||
|
||||
if (selectedFile) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2 bg-white/10 rounded-lg p-3 border border-white/20">
|
||||
<div className="flex items-center space-x-2 flex-1">
|
||||
{isImageType(selectedFile.type) ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={selectedFile.url}
|
||||
alt="Preview"
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{selectedFile.name}</p>
|
||||
<p className="text-white/60 text-xs">{formatFileSize(selectedFile.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-10 h-10 bg-blue-500/20 rounded flex items-center justify-center">
|
||||
<Paperclip className="h-5 w-5 text-blue-300" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{selectedFile.name}</p>
|
||||
<p className="text-white/60 text-xs">{formatFileSize(selectedFile.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
onClick={handleSendFile}
|
||||
size="sm"
|
||||
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border-green-500/30"
|
||||
variant="outline"
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancelFile}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Кнопка для загрузки изображений */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||
>
|
||||
<Image className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Кнопка для загрузки файлов */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Скрытые input элементы */}
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => handleFileInputChange(e, 'IMAGE')}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.json"
|
||||
onChange={(e) => handleFileInputChange(e, 'FILE')}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{isUploading && (
|
||||
<div className="text-white/60 text-xs">
|
||||
Загрузка...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
129
src/components/ui/image-message.tsx
Normal file
129
src/components/ui/image-message.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Download, Eye } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ImageMessageProps {
|
||||
imageUrl: string
|
||||
fileName: string
|
||||
fileSize?: number
|
||||
isCurrentUser?: boolean
|
||||
}
|
||||
|
||||
export function ImageMessage({ imageUrl, fileName, fileSize, isCurrentUser = false }: ImageMessageProps) {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showFullSize, setShowFullSize] = useState(false)
|
||||
|
||||
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 = () => {
|
||||
const link = document.createElement('a')
|
||||
link.href = imageUrl
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const handleImageClick = () => {
|
||||
setShowFullSize(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`relative group max-w-xs ${
|
||||
isCurrentUser
|
||||
? 'bg-blue-500/20 border border-blue-500/30'
|
||||
: 'bg-white/10 border border-white/20'
|
||||
} rounded-lg overflow-hidden`}>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={fileName}
|
||||
className="w-full h-auto cursor-pointer transition-opacity duration-200"
|
||||
style={{
|
||||
opacity: isLoading ? 0 : 1,
|
||||
maxHeight: '300px',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onClick={handleImageClick}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-gray-700 animate-pulse flex items-center justify-center">
|
||||
<div className="text-white/60 text-sm">Загрузка...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<Button
|
||||
onClick={handleImageClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о файле */}
|
||||
<div className="p-2">
|
||||
<p className={`text-xs ${
|
||||
isCurrentUser ? 'text-blue-300/70' : 'text-white/60'
|
||||
} truncate`}>
|
||||
{fileName}
|
||||
</p>
|
||||
{fileSize && (
|
||||
<p className={`text-xs ${
|
||||
isCurrentUser ? 'text-blue-300/50' : 'text-white/40'
|
||||
}`}>
|
||||
{formatFileSize(fileSize)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Полноэкранный просмотр */}
|
||||
{showFullSize && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setShowFullSize(false)}
|
||||
>
|
||||
<div className="relative max-w-full max-h-full">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={fileName}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
174
src/components/ui/voice-player.tsx
Normal file
174
src/components/ui/voice-player.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Play, Pause, Volume2 } from 'lucide-react'
|
||||
|
||||
interface VoicePlayerProps {
|
||||
audioUrl: string
|
||||
duration?: number
|
||||
isCurrentUser?: boolean
|
||||
}
|
||||
|
||||
export function VoicePlayer({ audioUrl, duration = 0, isCurrentUser = false }: VoicePlayerProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [audioDuration, setAudioDuration] = useState(duration > 0 ? duration : 0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
// Обновляем длительность при изменении props
|
||||
useEffect(() => {
|
||||
if (duration > 0 && (!audioDuration || audioDuration === 0)) {
|
||||
setAudioDuration(duration)
|
||||
}
|
||||
}, [duration, audioDuration])
|
||||
|
||||
useEffect(() => {
|
||||
// Создаем аудио элемент
|
||||
audioRef.current = new Audio(audioUrl)
|
||||
const audio = audioRef.current
|
||||
|
||||
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (audio.duration && isFinite(audio.duration) && !isNaN(audio.duration)) {
|
||||
setAudioDuration(audio.duration)
|
||||
} else {
|
||||
setAudioDuration(duration || 0)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (audio.currentTime && isFinite(audio.currentTime) && !isNaN(audio.currentTime)) {
|
||||
setCurrentTime(audio.currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
}
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleLoadStart = () => {
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
console.error('Audio loading error')
|
||||
setIsLoading(false)
|
||||
setIsPlaying(false)
|
||||
}
|
||||
|
||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
audio.addEventListener('timeupdate', handleTimeUpdate)
|
||||
audio.addEventListener('ended', handleEnded)
|
||||
audio.addEventListener('canplay', handleCanPlay)
|
||||
audio.addEventListener('loadstart', handleLoadStart)
|
||||
audio.addEventListener('error', handleError)
|
||||
|
||||
return () => {
|
||||
if (audio) {
|
||||
audio.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
audio.removeEventListener('timeupdate', handleTimeUpdate)
|
||||
audio.removeEventListener('ended', handleEnded)
|
||||
audio.removeEventListener('canplay', handleCanPlay)
|
||||
audio.removeEventListener('loadstart', handleLoadStart)
|
||||
audio.removeEventListener('error', handleError)
|
||||
audio.pause()
|
||||
}
|
||||
}
|
||||
}, [audioUrl])
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
audio.play().then(() => {
|
||||
setIsPlaying(true)
|
||||
}).catch((error) => {
|
||||
console.error('Error playing audio:', error)
|
||||
setIsPlaying(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
if (isNaN(time) || !isFinite(time) || time < 0) return '0:00'
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const getProgress = () => {
|
||||
if (!audioDuration || audioDuration === 0 || isNaN(audioDuration) || !isFinite(audioDuration)) return 0
|
||||
if (!currentTime || isNaN(currentTime) || !isFinite(currentTime)) return 0
|
||||
return Math.min((currentTime / audioDuration) * 100, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center space-x-4 p-3 rounded-lg min-w-[200px] max-w-sm ${
|
||||
isCurrentUser
|
||||
? 'bg-blue-500/20 border border-blue-500/30'
|
||||
: 'bg-white/10 border border-white/20'
|
||||
}`}>
|
||||
{/* Кнопка воспроизведения */}
|
||||
<Button
|
||||
onClick={togglePlayPause}
|
||||
disabled={isLoading}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`p-2 rounded-full ${
|
||||
isCurrentUser
|
||||
? 'text-blue-300 hover:text-blue-200 hover:bg-blue-500/30'
|
||||
: 'text-white hover:text-white/80 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Визуализация волны / прогресс бар */}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Volume2 className={`h-3 w-3 ${
|
||||
isCurrentUser ? 'text-blue-300' : 'text-white/60'
|
||||
}`} />
|
||||
<div className="flex-1 h-1 bg-white/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
isCurrentUser ? 'bg-blue-400' : 'bg-white/60'
|
||||
}`}
|
||||
style={{ width: `${getProgress()}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Время */}
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className={`${isCurrentUser ? 'text-blue-300/70' : 'text-white/50'} min-w-[2rem]`}>
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
<span className={`${isCurrentUser ? 'text-blue-300/70' : 'text-white/50'} min-w-[2rem] text-right`}>
|
||||
{formatTime(audioDuration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
254
src/components/ui/voice-recorder.tsx
Normal file
254
src/components/ui/voice-recorder.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Mic, MicOff, Square, Send, Trash2 } from 'lucide-react'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
interface VoiceRecorderProps {
|
||||
onSendVoice: (audioUrl: string, duration: number) => void
|
||||
}
|
||||
|
||||
export function VoiceRecorder({ onSendVoice }: VoiceRecorderProps) {
|
||||
const { user } = useAuth()
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordedAudio, setRecordedAudio] = useState<string | null>(null)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [permission, setPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt')
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем доступность микрофона
|
||||
if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
|
||||
navigator.permissions.query({ name: 'microphone' as PermissionName }).then((result) => {
|
||||
setPermission(result.state as 'granted' | 'denied' | 'prompt')
|
||||
}).catch(() => {
|
||||
// Если permissions API недоступен, оставляем prompt
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
setPermission('granted')
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: 'audio/webm;codecs=opus'
|
||||
})
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
audioChunksRef.current = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
|
||||
const audioUrl = URL.createObjectURL(audioBlob)
|
||||
setRecordedAudio(audioUrl)
|
||||
|
||||
// Останавливаем все треки для освобождения микрофона
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
|
||||
// Останавливаем таймер
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setDuration(0)
|
||||
|
||||
// Запускаем таймер записи
|
||||
intervalRef.current = setInterval(() => {
|
||||
setDuration(prev => prev + 1)
|
||||
}, 1000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error accessing microphone:', error)
|
||||
setPermission('denied')
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop()
|
||||
setIsRecording(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop()
|
||||
setIsRecording(false)
|
||||
}
|
||||
setRecordedAudio(null)
|
||||
setDuration(0)
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const playRecording = () => {
|
||||
if (recordedAudio) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.currentTime = 0
|
||||
}
|
||||
|
||||
audioRef.current = new Audio(recordedAudio)
|
||||
audioRef.current.play()
|
||||
setIsPlaying(true)
|
||||
|
||||
audioRef.current.onended = () => {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendVoiceMessage = async () => {
|
||||
if (!recordedAudio || !user?.id) return
|
||||
|
||||
try {
|
||||
// Конвертируем Blob в File для загрузки
|
||||
const response = await fetch(recordedAudio)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' })
|
||||
|
||||
// Загружаем в S3
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('userId', user.id)
|
||||
|
||||
const uploadResponse = await fetch('/api/upload-voice', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Failed to upload voice message')
|
||||
}
|
||||
|
||||
const result = await uploadResponse.json()
|
||||
|
||||
// Отправляем голосовое сообщение
|
||||
onSendVoice(result.url, duration)
|
||||
|
||||
// Очищаем состояние
|
||||
setRecordedAudio(null)
|
||||
setDuration(0)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending voice message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
if (permission === 'denied') {
|
||||
return (
|
||||
<div className="text-white/60 text-xs text-center p-2">
|
||||
Доступ к микрофону запрещен
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{!recordedAudio ? (
|
||||
// Состояние записи
|
||||
<>
|
||||
{!isRecording ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={startRecording}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 bg-red-500/20 rounded-lg px-3 py-2">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
||||
<span className="text-white text-sm font-mono">
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={stopRecording}
|
||||
className="text-white/80 hover:text-white p-1"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelRecording}
|
||||
className="text-red-400 hover:text-red-300 p-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Состояние воспроизведения и отправки
|
||||
<div className="flex items-center space-x-2 bg-blue-500/20 rounded-lg px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={playRecording}
|
||||
className="text-blue-300 hover:text-blue-200 p-1"
|
||||
>
|
||||
{isPlaying ? <MicOff className="h-3 w-3" /> : <Mic className="h-3 w-3" />}
|
||||
</Button>
|
||||
<span className="text-white text-sm font-mono">
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={sendVoiceMessage}
|
||||
className="text-green-400 hover:text-green-300 p-1"
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRecordedAudio(null)}
|
||||
className="text-red-400 hover:text-red-300 p-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user