mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 15:55:24 +02:00
feat: add voice-to-text input using Web Speech API
This commit is contained in:
@@ -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)"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
interface UseSpeechRecognitionOptions {
|
||||
onResult: (transcript: string) => void;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
interface UseSpeechRecognitionReturn {
|
||||
isListening: boolean;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
isSupported: boolean;
|
||||
}
|
||||
|
||||
export function useSpeechRecognition({ onResult, lang = 'en-US' }: UseSpeechRecognitionOptions): UseSpeechRecognitionReturn {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
|
||||
const isSupported = typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSupported) return;
|
||||
|
||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.lang = lang;
|
||||
|
||||
recognition.onresult = (event: any) => {
|
||||
let transcript = '';
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript;
|
||||
}
|
||||
if (event.results[event.resultIndex].isFinal) {
|
||||
onResult(transcript);
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
|
||||
return () => {
|
||||
try {
|
||||
recognition.stop();
|
||||
} catch (e) {}
|
||||
};
|
||||
}, [isSupported, lang, onResult]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (!recognitionRef.current || isListening) return;
|
||||
try {
|
||||
recognitionRef.current.start();
|
||||
setIsListening(true);
|
||||
} catch (e) {}
|
||||
}, [isListening]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!recognitionRef.current || !isListening) return;
|
||||
try {
|
||||
recognitionRef.current.stop();
|
||||
setIsListening(false);
|
||||
} catch (e) {}
|
||||
}, [isListening]);
|
||||
|
||||
return { isListening, start, stop, isSupported };
|
||||
}
|
||||
Reference in New Issue
Block a user