'use client'; /** * Command Input * * Bottom input bar for text and voice commands. * Height: 64px (h-16) * * Features: * - Text input with dynamic placeholder * - Focus state with ring * - Send button (appears when input has value) * - Voice input with Deepgram streaming * - ⌘K shortcut hint */ import { forwardRef, useState, useEffect, useRef } from 'react'; import { useSwiftStore } from '@/stores/swift-store'; import { useSwiftVoice } from '@/lib/swift/use-swift-voice'; import { Mic, MicOff, Send, Loader2 } from 'lucide-react'; export const CommandInput = forwardRef(function CommandInput(_, ref) { const { inputValue, setInputValue, clearInput, activePatient, activeBlock, isVoiceActive, } = useSwiftStore(); const { isRecording, isConnecting, isConnected, error: voiceError, startRecording, stopRecording, analyserNode, isBrowserSupported, } = useSwiftVoice(); const [isProcessing, setIsProcessing] = useState(false); const waveformRef = useRef(null); const animationRef = useRef(null); const hasValue = inputValue.trim().length > 0; // Waveform visualization useEffect(() => { if (!analyserNode || !waveformRef.current || !isRecording) { if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } return; } const canvas = waveformRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; const bufferLength = analyserNode.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); const draw = () => { if (!isRecording) return; animationRef.current = requestAnimationFrame(draw); analyserNode.getByteFrequencyData(dataArray); ctx.fillStyle = 'rgb(30, 41, 59)'; // slate-800 ctx.fillRect(0, 0, canvas.width, canvas.height); const barWidth = (canvas.width / bufferLength) * 2.5; let x = 0; for (let i = 0; i < bufferLength; i++) { const barHeight = (dataArray[i] / 255) * canvas.height; // Gradient from blue to red based on amplitude const hue = 220 - (dataArray[i] / 255) * 40; ctx.fillStyle = `hsl(${hue}, 70%, 60%)`; ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight); x += barWidth + 1; } }; draw(); return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } }; }, [analyserNode, isRecording]); // Dynamic placeholder based on context const getPlaceholder = () => { if (isRecording) return 'Luisteren...'; if (isConnecting) return 'Verbinden met spraakherkenning...'; if (activePatient) { return `Actie voor ${activePatient.name_given[0]}... (bijv. "notitie medicatie")`; } return 'Typ of spreek je intentie... (bijv. "notitie jan medicatie")'; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!hasValue || isProcessing) return; // Stop recording if active if (isRecording) { stopRecording(); } setIsProcessing(true); try { // TODO: Process intent (E2) console.log('Submit:', inputValue); clearInput(); } finally { setIsProcessing(false); } }; const handleVoiceToggle = () => { if (isRecording) { stopRecording(); } else { startRecording(); } }; const isDisabled = isProcessing || activeBlock !== null; return ( ); });