2026-01-13 18:47:57 +00:00
|
|
|
import React, {
|
|
|
|
|
useState,
|
|
|
|
|
useCallback,
|
|
|
|
|
useRef,
|
|
|
|
|
useEffect,
|
|
|
|
|
lazy,
|
|
|
|
|
Suspense,
|
|
|
|
|
} from 'react';
|
|
|
|
|
import {
|
|
|
|
|
Send,
|
|
|
|
|
Smile,
|
|
|
|
|
Paperclip,
|
|
|
|
|
X,
|
|
|
|
|
Image as ImageIcon,
|
|
|
|
|
File,
|
|
|
|
|
Mic,
|
|
|
|
|
} from 'lucide-react';
|
2025-12-03 21:56:50 +00:00
|
|
|
import { useChat } from '../hooks/useChat';
|
|
|
|
|
import { useChatStore } from '../store/chatStore';
|
2026-01-07 09:32:53 +00:00
|
|
|
import { Theme } from 'emoji-picker-react';
|
2026-01-04 00:41:51 +00:00
|
|
|
import { useDropzone } from 'react-dropzone';
|
|
|
|
|
import { apiClient } from '@/services/api/client';
|
|
|
|
|
import { MessageAttachment } from '../types';
|
2026-01-07 09:32:53 +00:00
|
|
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
|
|
|
|
import { logger } from '@/utils/logger';
|
2026-01-11 02:20:52 +00:00
|
|
|
import { cn } from '@/lib/utils';
|
2026-01-25 11:33:46 +00:00
|
|
|
import { Button } from '@/components/ui/button';
|
2026-01-15 19:01:47 +00:00
|
|
|
import { useIsRateLimited } from '@/hooks/useIsRateLimited';
|
2026-01-11 02:20:52 +00:00
|
|
|
|
|
|
|
|
// Lazy load
|
2026-01-13 18:47:57 +00:00
|
|
|
const EmojiPicker = lazy(() =>
|
|
|
|
|
import('emoji-picker-react').then((module) => ({ default: module.default })),
|
|
|
|
|
);
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
export const ChatInput: React.FC = () => {
|
|
|
|
|
const [message, setMessage] = useState('');
|
2026-01-04 00:41:51 +00:00
|
|
|
const [attachments, setAttachments] = useState<MessageAttachment[]>([]);
|
|
|
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
|
|
|
|
const { sendMessage, setTyping } = useChat();
|
2025-12-03 21:56:50 +00:00
|
|
|
const { currentConversationId } = useChatStore();
|
2026-01-15 19:01:47 +00:00
|
|
|
const isRateLimited = useIsRateLimited();
|
2026-01-04 00:41:51 +00:00
|
|
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
2026-01-04 00:41:51 +00:00
|
|
|
if ((message.trim() || attachments.length > 0) && currentConversationId) {
|
|
|
|
|
sendMessage(message, attachments.length > 0 ? attachments : undefined);
|
2025-12-03 21:56:50 +00:00
|
|
|
setMessage('');
|
2026-01-04 00:41:51 +00:00
|
|
|
setAttachments([]);
|
|
|
|
|
|
2026-01-11 02:20:52 +00:00
|
|
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
2026-01-04 00:41:51 +00:00
|
|
|
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, {
|
2026-01-13 18:47:57 +00:00
|
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
2026-01-04 00:41:51 +00:00
|
|
|
});
|
|
|
|
|
const data = response.data;
|
|
|
|
|
return {
|
|
|
|
|
file_name: file.name,
|
|
|
|
|
file_type: file.type,
|
2026-01-11 02:20:52 +00:00
|
|
|
file_url: data.url,
|
2026-01-04 00:41:51 +00:00
|
|
|
file_size: file.size,
|
|
|
|
|
} as MessageAttachment;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const newAttachments = await Promise.all(uploadPromises);
|
|
|
|
|
setAttachments((prev) => [...prev, ...newAttachments]);
|
|
|
|
|
} catch (error) {
|
2026-01-07 09:32:53 +00:00
|
|
|
logger.error('Failed to upload files', {
|
2026-01-13 18:47:57 +00:00
|
|
|
error: error instanceof Error ? error.message : String(error),
|
2026-01-07 09:32:53 +00:00
|
|
|
});
|
2026-01-04 00:41:51 +00:00
|
|
|
} finally {
|
|
|
|
|
setIsUploading(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
|
|
|
onDrop,
|
2026-01-11 02:20:52 +00:00
|
|
|
noClick: true,
|
2026-01-04 00:41:51 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
2026-01-11 02:20:52 +00:00
|
|
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
2026-01-04 00:41:51 +00:00
|
|
|
typingTimeoutRef.current = setTimeout(() => {
|
|
|
|
|
setTyping(false);
|
2026-01-11 02:20:52 +00:00
|
|
|
}, 3000);
|
2026-01-04 00:41:51 +00:00
|
|
|
} else {
|
|
|
|
|
setTyping(false);
|
|
|
|
|
}
|
|
|
|
|
}, [message, setTyping]);
|
|
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
return (
|
2026-01-11 02:20:52 +00:00
|
|
|
<div {...getRootProps()} className="relative">
|
|
|
|
|
<input {...getInputProps()} ref={fileInputRef} className="hidden" />
|
|
|
|
|
|
|
|
|
|
{/* Upload Overlay */}
|
|
|
|
|
{isDragActive && (
|
2026-01-16 10:40:13 +00:00
|
|
|
<div className="absolute bottom-full left-0 right-0 h-48 z-50 bg-kodo-steel/10 backdrop-blur-md flex items-center justify-center border-t-2 border-kodo-steel border-dashed rounded-t-2xl animate-fadeIn">
|
2026-01-11 02:20:52 +00:00
|
|
|
<div className="text-center">
|
2026-01-16 10:40:13 +00:00
|
|
|
<div className="w-12 h-12 rounded-full bg-kodo-steel/20 flex items-center justify-center mx-auto mb-2 animate-bounce">
|
|
|
|
|
<Paperclip className="w-6 h-6 text-kodo-steel" />
|
2026-01-11 02:20:52 +00:00
|
|
|
</div>
|
2026-01-16 10:40:13 +00:00
|
|
|
<p className="text-kodo-steel font-mono uppercase tracking-widest text-sm">
|
2026-01-13 18:47:57 +00:00
|
|
|
Initiate Data Transfer
|
|
|
|
|
</p>
|
2026-01-11 02:20:52 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Attachments Preview */}
|
2026-01-04 00:41:51 +00:00
|
|
|
{attachments.length > 0 && (
|
aesthetic-improvements: align spacing to 8px grid (Action 11.2.1.3)
- Created automated script (scripts/align-8px-grid.py) to align all spacing to 8px grid
- Replaced non-8px-aligned spacing: gap-3/p-3/m-3 (12px) → gap-4/p-4/m-4 (16px), gap-5/p-5/m-5 (20px) → gap-6/p-6/m-6 (24px), gap-10/p-10/m-10 (40px) → gap-12/p-12/m-12 (48px), gap-20/p-20/m-20 (80px) → gap-24/p-24/m-24 (96px)
- Preserved: 4px values (gap-1, p-1, m-1) as they may be intentional fine-tuning, responsive breakpoints (sm:, md:, lg:), test files, documentation
- Modified files across all components to ensure consistent 8px grid alignment
- Action 11.2.1.3: Align all elements to 8px grid - COMPLETE
2026-01-16 10:50:46 +00:00
|
|
|
<div className="absolute bottom-full left-0 right-0 p-4 bg-kodo-void/90 backdrop-blur-xl border-t border-white/10 flex gap-2 overflow-x-auto">
|
2026-01-04 00:41:51 +00:00
|
|
|
{attachments.map((att, i) => (
|
2026-01-13 18:47:57 +00:00
|
|
|
<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]"
|
|
|
|
|
>
|
2026-01-04 00:41:51 +00:00
|
|
|
{att.file_type.startsWith('image') ? (
|
2026-01-11 02:20:52 +00:00
|
|
|
<ImageIcon size={14} className="text-kodo-cyan" />
|
2026-01-04 00:41:51 +00:00
|
|
|
) : (
|
2026-01-11 02:20:52 +00:00
|
|
|
<File size={14} className="text-kodo-secondary" />
|
2026-01-04 00:41:51 +00:00
|
|
|
)}
|
2026-01-11 02:20:52 +00:00
|
|
|
<span className="truncate flex-1">{att.file_name}</span>
|
2026-01-04 00:41:51 +00:00
|
|
|
<button
|
|
|
|
|
onClick={() => removeAttachment(i)}
|
2026-01-11 02:20:52 +00:00
|
|
|
className="p-1 hover:bg-white/10 rounded-full text-kodo-red opacity-0 group-hover:opacity-100 transition-opacity"
|
2026-01-04 00:41:51 +00:00
|
|
|
>
|
|
|
|
|
<X size={12} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-11 02:20:52 +00:00
|
|
|
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
2026-01-04 00:41:51 +00:00
|
|
|
<div className="flex gap-1">
|
2026-01-11 02:20:52 +00:00
|
|
|
<Button
|
2026-01-04 00:41:51 +00:00
|
|
|
type="button"
|
2026-01-11 02:20:52 +00:00
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
2026-01-16 09:51:30 +00:00
|
|
|
className="text-kodo-secondary hover:text-white hover:bg-white/5"
|
2026-01-11 02:20:52 +00:00
|
|
|
onClick={() => fileInputRef.current?.click()}
|
2026-01-04 00:41:51 +00:00
|
|
|
>
|
|
|
|
|
<Paperclip size={20} />
|
2026-01-11 02:20:52 +00:00
|
|
|
</Button>
|
2026-01-04 00:41:51 +00:00
|
|
|
|
|
|
|
|
<div className="relative">
|
2026-01-11 02:20:52 +00:00
|
|
|
<Button
|
2026-01-04 00:41:51 +00:00
|
|
|
type="button"
|
2026-01-11 02:20:52 +00:00
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
2026-01-04 00:41:51 +00:00
|
|
|
className={cn(
|
2026-01-16 09:51:30 +00:00
|
|
|
'text-kodo-secondary hover:text-white hover:bg-white/5',
|
2026-01-16 10:40:13 +00:00
|
|
|
showEmojiPicker && 'text-kodo-steel bg-white/5',
|
2026-01-04 00:41:51 +00:00
|
|
|
)}
|
|
|
|
|
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
|
|
|
|
>
|
|
|
|
|
<Smile size={20} />
|
2026-01-11 02:20:52 +00:00
|
|
|
</Button>
|
2026-01-04 00:41:51 +00:00
|
|
|
|
|
|
|
|
{showEmojiPicker && (
|
2026-01-11 02:20:52 +00:00
|
|
|
<div className="absolute bottom-full left-0 mb-4 z-50 animate-scaleIn origin-bottom-left">
|
2026-01-13 18:47:57 +00:00
|
|
|
<div
|
|
|
|
|
className="fixed inset-0"
|
|
|
|
|
onClick={() => setShowEmojiPicker(false)}
|
|
|
|
|
/>
|
2026-01-11 02:20:52 +00:00
|
|
|
<div className="relative shadow-2xl rounded-xl overflow-hidden border border-white/10">
|
2026-01-13 18:47:57 +00:00
|
|
|
<Suspense
|
|
|
|
|
fallback={
|
|
|
|
|
<div className="w-[350px] h-[450px] bg-kodo-ink flex items-center justify-center">
|
|
|
|
|
<LoadingSpinner />
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
2026-01-07 09:32:53 +00:00
|
|
|
<EmojiPicker
|
|
|
|
|
onEmojiClick={handleEmojiClick}
|
2026-01-11 02:20:52 +00:00
|
|
|
theme={Theme.DARK}
|
2026-01-07 09:32:53 +00:00
|
|
|
lazyLoadEmojis={true}
|
2026-01-11 02:20:52 +00:00
|
|
|
width={350}
|
|
|
|
|
height={450}
|
2026-01-07 09:32:53 +00:00
|
|
|
/>
|
|
|
|
|
</Suspense>
|
2026-01-04 00:41:51 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-11 02:20:52 +00:00
|
|
|
<div className="flex-1 relative">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={message}
|
|
|
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
|
|
|
placeholder="Broadcast message..."
|
2026-01-16 10:40:13 +00:00
|
|
|
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-steel/50 focus:ring-1 focus:ring-kodo-steel/50 transition-all font-mono text-sm"
|
2026-01-11 02:20:52 +00:00
|
|
|
disabled={!currentConversationId || isUploading}
|
|
|
|
|
/>
|
|
|
|
|
{message.length === 0 && !isUploading && (
|
2026-01-26 13:12:17 +00:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 text-kodo-secondary/30 hover:text-white"
|
|
|
|
|
>
|
|
|
|
|
<Mic className="w-4 h-4" />
|
|
|
|
|
</Button>
|
2026-01-11 02:20:52 +00:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-04 00:41:51 +00:00
|
|
|
|
2026-01-11 02:20:52 +00:00
|
|
|
<Button
|
2026-01-04 00:41:51 +00:00
|
|
|
type="submit"
|
2026-01-18 21:27:53 +00:00
|
|
|
variant="primary"
|
2026-01-11 02:20:52 +00:00
|
|
|
size="icon"
|
|
|
|
|
className={cn(
|
2026-01-13 18:47:57 +00:00
|
|
|
'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',
|
2026-01-11 02:20:52 +00:00
|
|
|
)}
|
2026-01-13 18:47:57 +00:00
|
|
|
disabled={
|
|
|
|
|
!currentConversationId ||
|
|
|
|
|
(!message.trim() && attachments.length === 0) ||
|
2026-01-15 19:01:47 +00:00
|
|
|
isUploading ||
|
|
|
|
|
isRateLimited
|
2026-01-13 18:47:57 +00:00
|
|
|
}
|
2026-01-04 00:41:51 +00:00
|
|
|
>
|
|
|
|
|
{isUploading ? (
|
2026-01-11 02:20:52 +00:00
|
|
|
<div className="w-5 h-5 border-2 border-kodo-void/30 border-t-kodo-void rounded-full animate-spin" />
|
2026-01-04 00:41:51 +00:00
|
|
|
) : (
|
2026-01-13 18:47:57 +00:00
|
|
|
<Send
|
|
|
|
|
size={18}
|
|
|
|
|
className={cn(message.trim() ? 'translate-x-0.5' : '')}
|
|
|
|
|
/>
|
2026-01-04 00:41:51 +00:00
|
|
|
)}
|
2026-01-11 02:20:52 +00:00
|
|
|
</Button>
|
2026-01-04 00:41:51 +00:00
|
|
|
</form>
|
|
|
|
|
</div>
|
2025-12-03 21:56:50 +00:00
|
|
|
);
|
2026-01-13 18:47:57 +00:00
|
|
|
};
|