Spaces:
Running
Running
| import { useState, useRef } from "react"; | |
| export default function InputBar({ onSubmit, disabled }) { | |
| const [image, setImage] = useState(null); | |
| const [audio, setAudio] = useState(null); | |
| const [text, setText] = useState(""); | |
| const [isRecording, setIsRecording] = useState(false); | |
| const mediaRecorderRef = useRef(null); | |
| const audioChunksRef = useRef([]); | |
| const fileInputRef = useRef(null); | |
| const recordingTimeoutRef = useRef(null); | |
| function handleImageSelect(e) { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| setImage({ url: URL.createObjectURL(file), file }); | |
| } | |
| async function startRecording() { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| const recorder = new MediaRecorder(stream); | |
| audioChunksRef.current = []; | |
| recorder.ondataavailable = (e) => { | |
| if (e.data.size > 0) audioChunksRef.current.push(e.data); | |
| }; | |
| recorder.onstop = () => { | |
| clearTimeout(recordingTimeoutRef.current); | |
| const blob = new Blob(audioChunksRef.current, { type: "audio/webm" }); | |
| setAudio({ url: URL.createObjectURL(blob), blob }); | |
| stream.getTracks().forEach((t) => t.stop()); | |
| }; | |
| recordingTimeoutRef.current = setTimeout(() => { | |
| if (recorder.state === "recording") recorder.stop(); | |
| }, 30_000); | |
| mediaRecorderRef.current = recorder; | |
| recorder.start(); | |
| setIsRecording(true); | |
| } catch (err) { | |
| console.error("Mic access denied:", err); | |
| } | |
| } | |
| function stopRecording() { | |
| mediaRecorderRef.current?.stop(); | |
| setIsRecording(false); | |
| } | |
| function handleSubmit() { | |
| if (!image && !audio && !text.trim()) return; | |
| onSubmit({ | |
| imageUrl: image?.url || null, | |
| audioUrl: audio?.url || null, | |
| text: text.trim() || null, | |
| }); | |
| setImage(null); | |
| setAudio(null); | |
| setText(""); | |
| } | |
| const hasInput = image || audio || text.trim(); | |
| return ( | |
| <div className="px-4 py-3 border-t border-[var(--color-outline)]"> | |
| {/* Previews */} | |
| {(image || audio) && ( | |
| <div className="flex gap-2 mb-2"> | |
| {image && ( | |
| <div className="relative"> | |
| <img src={image.url} alt="Upload" className="h-16 w-16 object-cover rounded-xl border border-[var(--color-outline)]" /> | |
| <button | |
| onClick={() => { URL.revokeObjectURL(image.url); setImage(null); }} | |
| className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-[var(--color-surface-high)] rounded-full text-xs flex items-center justify-center hover:bg-[var(--color-surface)] cursor-pointer" | |
| > | |
| × | |
| </button> | |
| </div> | |
| )} | |
| {audio && ( | |
| <div className="flex items-center gap-2 px-2 py-1.5 bg-[var(--color-surface)] rounded-xl"> | |
| <audio controls src={audio.url} className="h-7 max-w-[200px]" /> | |
| <button | |
| onClick={() => { URL.revokeObjectURL(audio.url); setAudio(null); }} | |
| className="text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text)] cursor-pointer" | |
| > | |
| × | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Input row */} | |
| <div className="flex items-center gap-2"> | |
| <input ref={fileInputRef} type="file" accept="image/*" capture="environment" onChange={handleImageSelect} className="hidden" /> | |
| <button | |
| onClick={() => fileInputRef.current?.click()} | |
| disabled={disabled} | |
| className="p-2 rounded-full text-[var(--color-text-secondary)] hover:bg-[var(--color-surface)] disabled:opacity-30 transition-colors cursor-pointer" | |
| title="Add image" | |
| > | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg> | |
| </button> | |
| <button | |
| onClick={isRecording ? stopRecording : startRecording} | |
| disabled={disabled} | |
| className={`p-2 rounded-full disabled:opacity-30 transition-colors cursor-pointer ${ | |
| isRecording | |
| ? "text-[var(--color-red)] bg-red-500/10 animate-pulse" | |
| : "text-[var(--color-text-secondary)] hover:bg-[var(--color-surface)]" | |
| }`} | |
| title={isRecording ? "Stop recording" : "Record audio"} | |
| > | |
| {isRecording ? ( | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> | |
| ) : ( | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg> | |
| )} | |
| </button> | |
| <input | |
| type="text" | |
| value={text} | |
| onChange={(e) => setText(e.target.value)} | |
| onKeyDown={(e) => { if (e.key === "Enter" && hasInput && !disabled) handleSubmit(); }} | |
| placeholder="Message Gemma 4..." | |
| disabled={disabled} | |
| className="flex-1 bg-[var(--color-surface)] border border-[var(--color-outline)] rounded-xl px-4 py-2.5 text-sm text-[var(--color-text)] placeholder:text-[var(--color-text-secondary)]/50 focus:border-[var(--color-blue)]/50 focus:outline-none disabled:opacity-50" | |
| /> | |
| <button | |
| onClick={handleSubmit} | |
| disabled={disabled || !hasInput} | |
| className="px-5 py-2.5 bg-[var(--color-blue)] hover:bg-[var(--color-blue)]/90 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer" | |
| > | |
| Send | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |