veza/apps/web/src/features/chat/components/ChatInput.tsx

253 lines
8.7 KiB
TypeScript

import React, {
useState,
useCallback,
useRef,
useEffect,
lazy,
Suspense,
} from 'react';
import {
Send,
Smile,
Paperclip,
X,
Image as ImageIcon,
File,
Mic,
} from 'lucide-react';
import { useChat } from '../hooks/useChat';
import { useChatStore } from '../store/chatStore';
import { Theme } from 'emoji-picker-react';
import { useDropzone } from 'react-dropzone';
import { apiClient } from '@/services/api/client';
import { MessageAttachment } from '../types';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { logger } from '@/utils/logger';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
// Lazy load
const EmojiPicker = lazy(() =>
import('emoji-picker-react').then((module) => ({ default: module.default })),
);
export const ChatInput: React.FC = () => {
const [message, setMessage] = useState('');
const [attachments, setAttachments] = useState<MessageAttachment[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const { sendMessage, setTyping } = useChat();
const { currentConversationId } = useChatStore();
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if ((message.trim() || attachments.length > 0) && currentConversationId) {
sendMessage(message, attachments.length > 0 ? attachments : undefined);
setMessage('');
setAttachments([]);
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
setTyping(false);
}
};
const fileInputRef = useRef<HTMLInputElement>(null);
const onDrop = useCallback(async (acceptedFiles: File[]) => {
setIsUploading(true);
try {
const uploadPromises = acceptedFiles.map(async (file) => {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post('/uploads', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const data = response.data;
return {
file_name: file.name,
file_type: file.type,
file_url: data.url,
file_size: file.size,
} as MessageAttachment;
});
const newAttachments = await Promise.all(uploadPromises);
setAttachments((prev) => [...prev, ...newAttachments]);
} catch (error) {
logger.error('Failed to upload files', {
error: error instanceof Error ? error.message : String(error),
});
} finally {
setIsUploading(false);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
noClick: true,
});
const handleEmojiClick = (emojiData: { emoji: string }) => {
setMessage((prev) => prev + emojiData.emoji);
setShowEmojiPicker(false);
};
const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
};
useEffect(() => {
if (message.length > 0) {
setTyping(true);
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
setTyping(false);
}, 3000);
} else {
setTyping(false);
}
}, [message, setTyping]);
return (
<div {...getRootProps()} className="relative">
<input {...getInputProps()} ref={fileInputRef} className="hidden" />
{/* Upload Overlay */}
{isDragActive && (
<div className="absolute bottom-full left-0 right-0 h-48 z-50 bg-kodo-cyan/10 backdrop-blur-md flex items-center justify-center border-t-2 border-kodo-cyan border-dashed rounded-t-2xl animate-fadeIn">
<div className="text-center">
<div className="w-12 h-12 rounded-full bg-kodo-cyan/20 flex items-center justify-center mx-auto mb-2 animate-bounce">
<Paperclip className="w-6 h-6 text-kodo-cyan" />
</div>
<p className="text-kodo-cyan font-mono uppercase tracking-widest text-sm">
Initiate Data Transfer
</p>
</div>
</div>
)}
{/* Attachments Preview */}
{attachments.length > 0 && (
<div className="absolute bottom-full left-0 right-0 p-3 bg-kodo-void/90 backdrop-blur-xl border-t border-white/10 flex gap-2 overflow-x-auto">
{attachments.map((att, i) => (
<div
key={i}
className="relative group flex items-center gap-2 p-2 bg-white/5 rounded-lg border border-white/10 text-xs text-white min-w-[150px]"
>
{att.file_type.startsWith('image') ? (
<ImageIcon size={14} className="text-kodo-cyan" />
) : (
<File size={14} className="text-kodo-secondary" />
)}
<span className="truncate flex-1">{att.file_name}</span>
<button
onClick={() => removeAttachment(i)}
className="p-1 hover:bg-white/10 rounded-full text-kodo-red opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<div className="flex gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="text-kodo-secondary hover:text-kodo-cyan hover:bg-white/5"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip size={20} />
</Button>
<div className="relative">
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'text-kodo-secondary hover:text-kodo-cyan hover:bg-white/5',
showEmojiPicker && 'text-kodo-cyan bg-white/5',
)}
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
>
<Smile size={20} />
</Button>
{showEmojiPicker && (
<div className="absolute bottom-full left-0 mb-4 z-50 animate-scaleIn origin-bottom-left">
<div
className="fixed inset-0"
onClick={() => setShowEmojiPicker(false)}
/>
<div className="relative shadow-2xl rounded-xl overflow-hidden border border-white/10">
<Suspense
fallback={
<div className="w-[350px] h-[450px] bg-kodo-ink flex items-center justify-center">
<LoadingSpinner />
</div>
}
>
<EmojiPicker
onEmojiClick={handleEmojiClick}
theme={Theme.DARK}
lazyLoadEmojis={true}
width={350}
height={450}
/>
</Suspense>
</div>
</div>
)}
</div>
</div>
<div className="flex-1 relative">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Broadcast message..."
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder:text-kodo-secondary/50 focus:outline-none focus:border-kodo-cyan/50 focus:ring-1 focus:ring-kodo-cyan/50 transition-all font-mono text-sm"
disabled={!currentConversationId || isUploading}
/>
{message.length === 0 && !isUploading && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Mic className="w-4 h-4 text-kodo-secondary/30 hover:text-kodo-cyan cursor-pointer transition-colors" />
</div>
)}
</div>
<Button
type="submit"
variant="default"
size="icon"
className={cn(
'rounded-xl transition-all duration-300',
message.trim() || attachments.length > 0
? 'bg-kodo-cyan text-kodo-void hover:bg-kodo-cyan-dim shadow-neon-cyan'
: 'bg-white/5 text-kodo-secondary hover:bg-white/10',
)}
disabled={
!currentConversationId ||
(!message.trim() && attachments.length === 0) ||
isUploading
}
>
{isUploading ? (
<div className="w-5 h-5 border-2 border-kodo-void/30 border-t-kodo-void rounded-full animate-spin" />
) : (
<Send
size={18}
className={cn(message.trim() ? 'translate-x-0.5' : '')}
/>
)}
</Button>
</form>
</div>
);
};