mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 17:15:23 +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 { 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 { generateGemini } from '../services/gemini';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { useSpeechRecognition } from '../lib/useSpeechRecognition';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
|
|
||||||
interface EditPanelProps {
|
interface EditPanelProps {
|
||||||
@@ -49,6 +50,9 @@ interface FieldEditorProps {
|
|||||||
onGenerate: () => void;
|
onGenerate: () => void;
|
||||||
onChange: (val: string) => void;
|
onChange: (val: string) => void;
|
||||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
|
isListening?: boolean;
|
||||||
|
onToggleVoice?: () => void;
|
||||||
|
voiceSupported?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FieldEditor = ({
|
const FieldEditor = ({
|
||||||
@@ -61,7 +65,10 @@ const FieldEditor = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
onChange,
|
onChange,
|
||||||
onKeyDown
|
onKeyDown,
|
||||||
|
isListening,
|
||||||
|
onToggleVoice,
|
||||||
|
voiceSupported
|
||||||
}: FieldEditorProps) => (
|
}: FieldEditorProps) => (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -73,6 +80,21 @@ const FieldEditor = ({
|
|||||||
Can translate from existing
|
Can translate from existing
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={onGenerate}
|
onClick={onGenerate}
|
||||||
disabled={isLoading}
|
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 [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
|
||||||
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
|
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 isModified = (field: keyof typeof formData) => {
|
||||||
const colMap: Record<string, number> = {
|
const colMap: Record<string, number> = {
|
||||||
longDe: COLUMNS.LONG_DE,
|
longDe: COLUMNS.LONG_DE,
|
||||||
@@ -351,6 +384,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
onGenerate={() => handleGenerate('longDe')}
|
onGenerate={() => handleGenerate('longDe')}
|
||||||
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
|
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
|
isListening={isListeningDe}
|
||||||
|
onToggleVoice={isListeningDe ? stopListeningDe : startListeningDe}
|
||||||
|
voiceSupported={speechSupported}
|
||||||
/>
|
/>
|
||||||
<FieldEditor
|
<FieldEditor
|
||||||
title="Long Description (EN)"
|
title="Long Description (EN)"
|
||||||
@@ -363,6 +399,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
onGenerate={() => handleGenerate('longEn')}
|
onGenerate={() => handleGenerate('longEn')}
|
||||||
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
|
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
|
isListening={isListeningEn}
|
||||||
|
onToggleVoice={isListeningEn ? stopListeningEn : startListeningEn}
|
||||||
|
voiceSupported={speechSupported}
|
||||||
/>
|
/>
|
||||||
<FieldEditor
|
<FieldEditor
|
||||||
title="Short Description (DE)"
|
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