UNPKG

aura-glass

Version:

A comprehensive glassmorphism design system for React applications with 142+ production-ready components

439 lines (436 loc) 16.5 kB
'use client'; import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; import { cn } from '../../lib/utilsComprehensive.js'; import { Image, Video, Mic, File, X, Paperclip, Square, Bold, Italic, Code, Link, Smile, Plus, Send } from 'lucide-react'; import { useState, useRef, useEffect, useCallback } from 'react'; import '../../primitives/GlassCore.js'; import '../../primitives/glass/GlassAdvanced.js'; import '../../primitives/OptimizedGlassCore.js'; import '../../primitives/glass/OptimizedGlassAdvanced.js'; import '../../primitives/MotionNative.js'; import { MotionFramer } from '../../primitives/motion/MotionFramer.js'; import { GlassButton } from '../button/GlassButton.js'; import '../button/GlassFab.js'; import '../button/GlassMagneticButton.js'; import { CardContent } from '../card/index.js'; import { GlassCard } from '../card/GlassCard.js'; /** * GlassChatInput component * A rich chat input with attachments, voice recording, and formatting */ const GlassChatInput = ({ placeholder = "Type a message...", enableAttachments = true, enableVoice = false, enableEmoji = true, enableFormatting = false, enableMentions = false, maxLength, maxFileSize = 10 * 1024 * 1024, // 10MB acceptedFileTypes = "image/*,video/*,audio/*,.pdf,.doc,.docx,.txt", showCharCount = false, autoResize = true, disabled = false, loading = false, onSendMessage, onTyping, onAttachmentAdd, onAttachmentRemove, onVoiceRecording, className, ...props }) => { const [message, setMessage] = useState(""); const [attachments, setAttachments] = useState([]); const [isRecording, setIsRecording] = useState(false); const [recordingTime, setRecordingTime] = useState(0); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showFormatting, setShowFormatting] = useState(false); const [isTyping, setIsTyping] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); const recordingIntervalRef = useRef(); const typingTimeoutRef = useRef(); // Auto-resize textarea useEffect(() => { if (autoResize && textareaRef.current) { textareaRef.current.style.height = "auto"; textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`; } }, [message, autoResize]); // Handle typing indicator useEffect(() => { if (!onTyping) return; if (message.length > 0 && !isTyping) { setIsTyping(true); onTyping(true); } if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } typingTimeoutRef.current = setTimeout(() => { if (isTyping) { setIsTyping(false); onTyping(false); } }, 2000); return () => { if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } }; }, [message, isTyping, onTyping]); // Handle input change const handleInputChange = useCallback(e => { let value = e.target.value; if (maxLength && value.length > maxLength) { value = value.substring(0, maxLength); } setMessage(value); }, [maxLength]); // Handle key press const handleKeyPress = useCallback(e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSendMessage(); } }, []); // Handle send message const handleSendMessage = useCallback(() => { if (!message.trim() && attachments.length === 0) return; if (disabled || loading) return; onSendMessage?.(message.trim(), attachments); // Reset state setMessage(""); setAttachments([]); setIsTyping(false); onTyping?.(false); // Reset textarea height if (textareaRef.current) { textareaRef.current.style.height = "auto"; } }, [message, attachments, disabled, loading, onSendMessage, onTyping]); // Handle file attachment const handleFileSelect = useCallback(() => { fileInputRef.current?.click(); }, []); const handleFileChange = useCallback(e => { const files = Array.from(e.target.files || []); files.forEach(file => { if (file.size > maxFileSize) { console.warn(`File ${file.name} is too large`); return; } const attachment = { id: `${Date.now()}-${Math.random()}`, file, type: file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : file.type.startsWith("audio/") ? "audio" : "file", size: file.size }; // Create preview for images if (attachment.type === "image") { const reader = new FileReader(); reader.onload = () => { attachment.preview = reader.result; setAttachments(prev => [...prev, attachment]); onAttachmentAdd?.(attachment); }; reader.readAsDataURL(file); } else { setAttachments(prev => [...prev, attachment]); onAttachmentAdd?.(attachment); } }); e.target.value = ""; }, [maxFileSize, onAttachmentAdd]); // Remove attachment const handleRemoveAttachment = useCallback(attachmentId => { setAttachments(prev => prev.filter(att => att.id !== attachmentId)); onAttachmentRemove?.(attachmentId); }, [onAttachmentRemove]); // Handle voice recording const handleVoiceToggle = useCallback(() => { if (isRecording) { // Stop recording setIsRecording(false); setRecordingTime(0); if (recordingIntervalRef.current) { clearInterval(recordingIntervalRef.current); } // Here you would typically get the recorded blob and call onVoiceRecording // For now, we'll just simulate it const mockBlob = new Blob(["mock audio data"], { type: "audio/wav" }); onVoiceRecording?.(mockBlob); } else { // Start recording setIsRecording(true); recordingIntervalRef.current = setInterval(() => { setRecordingTime(prev => prev + 1); }, 1000); } }, [isRecording, onVoiceRecording]); // Format recording time const formatRecordingTime = useCallback(seconds => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, "0")}`; }, []); // Handle emoji selection const handleEmojiSelect = useCallback(emoji => { setMessage(prev => prev + emoji); setShowEmojiPicker(false); }, []); // Handle formatting const handleFormatting = useCallback(format => { const textarea = textareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; const selectedText = message.substring(start, end); let formattedText = ""; switch (format) { case "bold": formattedText = `**${selectedText}**`; break; case "italic": formattedText = `*${selectedText}*`; break; case "code": formattedText = `\`${selectedText}\``; break; case "link": formattedText = `[${selectedText}](url)`; break; case "quote": formattedText = `> ${selectedText}`; break; case "list": formattedText = `- ${selectedText}`; break; } const newMessage = message.substring(0, start) + formattedText + message.substring(end); setMessage(newMessage); // Reset cursor position setTimeout(() => { textarea.focus(); textarea.setSelectionRange(start + formattedText.length, start + formattedText.length); }, 0); }, [message]); // Get file type icon const getFileIcon = useCallback(type => { if (type.startsWith("image/")) return jsx(Image, { className: 'w-4 h-4' }); if (type.startsWith("video/")) return jsx(Video, { className: 'w-4 h-4' }); if (type.startsWith("audio/")) return jsx(Mic, { className: 'w-4 h-4' }); return jsx(File, { className: 'w-4 h-4' }); }, []); // Format file size const formatFileSize = useCallback(bytes => { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; }, []); return jsx(MotionFramer, { "data-glass-component": true, preset: "fadeIn", className: "glass-w-full", children: jsx(GlassCard, { className: cn("overflow-hidden", className), ...props, children: jsxs(CardContent, { className: "glass-p-4", children: [attachments.length > 0 && jsx("div", { className: 'mb-4 glass-gap-2', role: "list", "aria-label": "Attached files", children: attachments.map(attachment => jsxs("div", { role: "listitem", className: "glass-flex glass-items-center glass-gap-3 glass-p-3 glass-surface-subtle/10 glass-radius-lg", children: [jsx("div", { className: "glass-flex-shrink-0", children: attachment.preview ? jsx("img", { src: attachment.preview, alt: attachment.file.name, className: 'w-10 h-10 glass-radius-md object-cover' }) : jsx("div", { className: 'w-10 h-10 glass-radius-md glass-surface-subtle/20 glass-flex glass-items-center glass-justify-center', children: getFileIcon(attachment.file.type) }) }), jsxs("div", { className: "glass-flex-1 glass-min-w-0", children: [jsx("p", { className: 'text-primary glass-text-sm truncate', children: attachment.file.name }), jsx("p", { className: 'text-primary/60 glass-text-xs', children: formatFileSize(attachment.size) })] }), jsx(GlassButton, { variant: "ghost", size: "sm", onClick: e => handleRemoveAttachment(attachment.id), className: "glass-p-1", children: jsx(X, { className: 'w-4 h-4' }) })] }, attachment.id)) }), jsxs("div", { className: "glass-flex glass-items-end glass-gap-3", children: [enableAttachments && jsx(GlassButton, { variant: "ghost", size: "sm", onClick: handleFileSelect, disabled: disabled || loading, "aria-label": "Attach file", className: "glass-p-2 glass-flex-shrink-0", children: jsx(Paperclip, { className: 'w-4 h-4', "aria-hidden": "true" }) }), enableVoice && jsx(GlassButton, { variant: isRecording ? "destructive" : "ghost", size: "sm", onClick: handleVoiceToggle, disabled: disabled || loading, "aria-label": isRecording ? `Stop recording (${formatRecordingTime(recordingTime)})` : "Start voice recording", "aria-pressed": isRecording, className: "glass-p-2 glass-flex-shrink-0", children: isRecording ? jsxs(Fragment, { children: [jsx(Square, { className: 'w-4 h-4 glass-mr-2', "aria-hidden": "true" }), jsx("span", { className: "glass-text-xs", "aria-hidden": "true", children: formatRecordingTime(recordingTime) })] }) : jsx(Mic, { className: 'w-4 h-4', "aria-hidden": "true" }) }), enableFormatting && showFormatting && jsxs("div", { className: 'glass-flex glass-gap-1 glass-p-2 glass-surface-subtle/10 glass-radius-lg mb-2', children: [jsx(GlassButton, { variant: "ghost", size: "sm", onClick: e => handleFormatting("bold"), className: "glass-p-1", children: jsx(Bold, { className: 'w-3 h-3' }) }), jsx(GlassButton, { variant: "ghost", size: "sm", onClick: e => handleFormatting("italic"), className: "glass-p-1", children: jsx(Italic, { className: 'w-3 h-3' }) }), jsx(GlassButton, { variant: "ghost", size: "sm", onClick: e => handleFormatting("code"), className: "glass-p-1", children: jsx(Code, { className: 'w-3 h-3' }) }), jsx(GlassButton, { variant: "ghost", size: "sm", onClick: e => handleFormatting("link"), className: "glass-p-1", children: jsx(Link, { className: 'w-3 h-3' }) })] }), jsxs("div", { className: 'glass-flex-1 relative', children: [jsx("textarea", { ref: textareaRef, id: "chat-message-input", value: message, onChange: handleInputChange, onKeyPress: handleKeyPress, placeholder: isRecording ? "Recording..." : placeholder, disabled: disabled || loading || isRecording, "aria-label": "Chat message", "aria-describedby": showCharCount && maxLength ? "char-count" : undefined, "aria-multiline": "true", className: cn("w-full bg-glass-fill ring-1 ring-white/10 glass-radius-lg glass-px-4 glass-py-3 glass-text-primary placeholder-white/50", "focus:outline-none focus:ring-white/30 resize-none min-h-[44px] max-h-[120px]", "disabled:opacity-50 disabled:cursor-not-allowed"), rows: 1 }), showCharCount && maxLength && jsxs("div", { id: "char-count", className: 'absolute bottom-2 right-3 glass-text-xs text-primary/50', "aria-live": "polite", children: [message.length, "/", maxLength] })] }), jsxs("div", { className: "glass-flex glass-gap-1 glass-flex-shrink-0", children: [enableEmoji && jsx(GlassButton, { variant: "ghost", size: "sm", onClick: e => setShowEmojiPicker(!showEmojiPicker), disabled: disabled || loading, className: "glass-p-2", children: jsx(Smile, { className: 'w-4 h-4' }) }), enableFormatting && jsx(GlassButton, { variant: showFormatting ? "primary" : "ghost", size: "sm", onClick: e => setShowFormatting(!showFormatting), disabled: disabled || loading, className: "glass-p-2", children: jsx(Plus, { className: 'w-4 h-4' }) }), jsx(GlassButton, { variant: "primary", size: "sm", onClick: handleSendMessage, disabled: !message.trim() && attachments.length === 0 || disabled || loading || isRecording, className: "glass-p-2", children: loading ? jsx("div", { className: 'w-4 h-4 glass-border-2 glass-border-white/20 glass-border-t-white glass-radius-full animate-spin' }) : jsx(Send, { className: 'w-4 h-4' }) })] })] }), showEmojiPicker && jsx("div", { className: 'mt-3 glass-p-3 glass-surface-subtle/10 glass-radius-lg', children: jsx("div", { className: "glass-grid glass-grid-cols-8 glass-gap-2", children: ["😀", "😂", "😊", "😍", "🤔", "😎", "👍", "👎", "❤️", "🔥", "⭐", "🎉", "🙌", "✨", "💯", "🚀"].map(emoji => jsx("button", { onClick: e => handleEmojiSelect(emoji), className: 'w-8 h-8 hover:glass-surface-subtle/20 glass-radius-md transition-colors glass-text-lg', children: emoji }, emoji)) }) }), jsx("input", { ref: fileInputRef, type: "file", multiple: true, onChange: handleFileChange, className: 'hidden glass-touch-target glass-contrast-guard', accept: acceptedFileTypes })] }) }) }); }; export { GlassChatInput, GlassChatInput as default }; //# sourceMappingURL=GlassChatInput.js.map