feat: add voice-to-text input using Web Speech API

This commit is contained in:
Christian Vidal Wolf
2026-04-11 11:34:57 +02:00
parent 5104dfd59d
commit 0e797e06bb
2 changed files with 116 additions and 3 deletions
+42 -3
View File
@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import React, { useState, useRef, useCallback } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2 } from 'lucide-react';
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2, Mic, MicOff } from 'lucide-react';
import { generateGemini } from '../services/gemini';
import { cn } from '../lib/utils';
import { useSpeechRecognition } from '../lib/useSpeechRecognition';
import { ConfirmModal } from './ConfirmModal';
interface EditPanelProps {
@@ -49,6 +50,9 @@ interface FieldEditorProps {
onGenerate: () => void;
onChange: (val: string) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
isListening?: boolean;
onToggleVoice?: () => void;
voiceSupported?: boolean;
}
const FieldEditor = ({
@@ -61,7 +65,10 @@ const FieldEditor = ({
isLoading,
onGenerate,
onChange,
onKeyDown
onKeyDown,
isListening,
onToggleVoice,
voiceSupported
}: FieldEditorProps) => (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
@@ -73,6 +80,21 @@ const FieldEditor = ({
Can translate from existing
</div>
)}
{voiceSupported && (
<button
onClick={onToggleVoice}
className={cn(
"flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded transition-colors",
isListening
? "bg-red-600/20 text-red-400 animate-pulse"
: "bg-slate-700/50 text-slate-400 hover:bg-slate-600 hover:text-white"
)}
title={isListening ? "Stop recording" : "Voice input"}
>
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
{isListening ? 'Stop' : 'Voice'}
</button>
)}
<button
onClick={onGenerate}
disabled={isLoading}
@@ -127,6 +149,17 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
const handleSpeechResult = useCallback((transcript: string) => {
setFormData(prev => ({ ...prev, longDe: prev.longDe + ' ' + transcript }));
}, []);
const { isListening: isListeningDe, start: startListeningDe, stop: stopListeningDe, isSupported: speechSupported } = useSpeechRecognition({ onResult: handleSpeechResult });
const handleSpeechResultEn = useCallback((transcript: string) => {
setFormData(prev => ({ ...prev, longEn: prev.longEn + ' ' + transcript }));
}, []);
const { isListening: isListeningEn, start: startListeningEn, stop: stopListeningEn } = useSpeechRecognition({ onResult: handleSpeechResultEn, lang: 'en-US' });
const isModified = (field: keyof typeof formData) => {
const colMap: Record<string, number> = {
longDe: COLUMNS.LONG_DE,
@@ -351,6 +384,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
onGenerate={() => handleGenerate('longDe')}
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
onKeyDown={handleInputKeyDown}
isListening={isListeningDe}
onToggleVoice={isListeningDe ? stopListeningDe : startListeningDe}
voiceSupported={speechSupported}
/>
<FieldEditor
title="Long Description (EN)"
@@ -363,6 +399,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
onGenerate={() => handleGenerate('longEn')}
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
onKeyDown={handleInputKeyDown}
isListening={isListeningEn}
onToggleVoice={isListeningEn ? stopListeningEn : startListeningEn}
voiceSupported={speechSupported}
/>
<FieldEditor
title="Short Description (DE)"