473 lines
19 KiB
TypeScript
473 lines
19 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { ChatHeader } from "@/components/ChatHeader";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Upload, Download, Trash2, FileAudio, Copy, Search, ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Pagination,
|
|
PaginationContent,
|
|
PaginationItem,
|
|
} from "@/components/ui/pagination";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { transcriptionService, TranscriptionRecord } from "@/services/transcription";
|
|
|
|
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
|
|
|
|
export const TranscriptionView = () => {
|
|
const [isTranscribing, setIsTranscribing] = useState(false);
|
|
const [lastTranscriptionResult, setLastTranscriptionResult] = useState<TranscriptionRecord | null>(null);
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [transcriptionHistory, setTranscriptionHistory] = useState<TranscriptionRecord[]>([]);
|
|
const [isLoadingTranscriptions, setIsLoadingTranscriptions] = useState(false);
|
|
const [transcriptionSearchQuery, setTranscriptionSearchQuery] = useState("");
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [perPage, setPerPage] = useState(10);
|
|
const { toast } = useToast();
|
|
|
|
// Carrega transcrições do banco de dados
|
|
const loadTranscriptions = async (page: number = currentPage, limit: number = perPage) => {
|
|
setIsLoadingTranscriptions(true);
|
|
try {
|
|
const fetchedTranscriptions = await transcriptionService.getTranscriptions(undefined, page, limit);
|
|
|
|
// Garante que sempre seja um array
|
|
if (Array.isArray(fetchedTranscriptions)) {
|
|
setTranscriptionHistory(fetchedTranscriptions);
|
|
} else {
|
|
console.warn('Resposta da API não é um array:', fetchedTranscriptions);
|
|
setTranscriptionHistory([]);
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Erro ao carregar transcrições:', error);
|
|
toast({
|
|
title: "Erro ao carregar histórico",
|
|
description: error.message || "Não foi possível carregar o histórico de transcrições.",
|
|
variant: "destructive",
|
|
});
|
|
setTranscriptionHistory([]);
|
|
} finally {
|
|
setIsLoadingTranscriptions(false);
|
|
}
|
|
};
|
|
|
|
// Carrega transcrições ao montar e quando a paginação mudar
|
|
useEffect(() => {
|
|
loadTranscriptions();
|
|
}, [currentPage, perPage]);
|
|
|
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// Valida o arquivo usando o serviço
|
|
const validation = transcriptionService.validateAudioFile(file);
|
|
if (!validation.valid) {
|
|
toast({
|
|
title: "Arquivo inválido",
|
|
description: validation.error,
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSelectedFile(file);
|
|
handleTranscription(file);
|
|
};
|
|
|
|
const handleTranscription = async (file: File) => {
|
|
setIsTranscribing(true);
|
|
|
|
try {
|
|
// Chama o serviço de transcrição
|
|
const response = await transcriptionService.transcribeAudio({
|
|
audioFile: file,
|
|
});
|
|
|
|
// Verifica se a transcrição foi bem-sucedida
|
|
if (response.success) {
|
|
// Cria objeto da transcrição recém-gerada para exibição imediata
|
|
const newTranscription: TranscriptionRecord = {
|
|
id: response.transcription_id,
|
|
user_email: '',
|
|
estabelecimento_id: 0,
|
|
audio_file_name: file.name,
|
|
audio_duration_seconds: 0,
|
|
transcription_text: response.message,
|
|
model: 'whisper-1',
|
|
audio_url: response.audio_url,
|
|
cost_usd: '0',
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
|
|
// Salva a última transcrição para exibição
|
|
setLastTranscriptionResult(newTranscription);
|
|
|
|
toast({
|
|
title: "Transcrição concluída",
|
|
description: "Seu áudio foi transcrito com sucesso!",
|
|
});
|
|
|
|
// Recarrega a lista de transcrições (sem aguardar para não bloquear a UI)
|
|
loadTranscriptions(1, perPage);
|
|
setCurrentPage(1);
|
|
} else {
|
|
throw new Error(response.message || 'Erro ao transcrever áudio');
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Erro na transcrição:', error);
|
|
|
|
toast({
|
|
title: "Erro na transcrição",
|
|
description: error.message || "Não foi possível transcrever o áudio. Tente novamente.",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsTranscribing(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteTranscription = () => {
|
|
setLastTranscriptionResult(null);
|
|
setSelectedFile(null);
|
|
};
|
|
|
|
const handleDeleteTranscriptionFromHistory = async (transcriptionId: string) => {
|
|
try {
|
|
const result = await transcriptionService.deleteTranscription(transcriptionId);
|
|
|
|
if (result.success) {
|
|
toast({
|
|
title: "Transcrição removida",
|
|
description: "A transcrição foi removida com sucesso.",
|
|
});
|
|
|
|
// Se a transcrição deletada for a última gerada, limpa o preview
|
|
if (lastTranscriptionResult && lastTranscriptionResult.id === transcriptionId) {
|
|
setLastTranscriptionResult(null);
|
|
}
|
|
|
|
// Recarrega a lista de transcrições
|
|
await loadTranscriptions();
|
|
} else {
|
|
throw new Error(result.message || 'Erro ao deletar transcrição');
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Erro ao deletar transcrição:', error);
|
|
toast({
|
|
title: "Erro ao remover",
|
|
description: error.message || "Não foi possível remover a transcrição.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleCopyTranscription = (text: string) => {
|
|
navigator.clipboard.writeText(text);
|
|
toast({
|
|
title: "Texto copiado",
|
|
description: "A transcrição foi copiada para a área de transferência",
|
|
});
|
|
};
|
|
|
|
const handleDownloadTranscription = (transcription: TranscriptionRecord) => {
|
|
const blob = new Blob([transcription.transcription_text], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `transcricao_${transcription.audio_file_name}.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
toast({
|
|
title: "Download iniciado",
|
|
description: "O arquivo de transcrição está sendo baixado.",
|
|
});
|
|
};
|
|
|
|
const handlePageChange = (page: number) => {
|
|
setCurrentPage(page);
|
|
};
|
|
|
|
const handlePerPageChange = (value: string) => {
|
|
setPerPage(parseInt(value));
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
const filteredTranscriptions = Array.isArray(transcriptionHistory)
|
|
? transcriptionHistory.filter(item =>
|
|
item.audio_file_name.toLowerCase().includes(transcriptionSearchQuery.toLowerCase()) ||
|
|
item.transcription_text.toLowerCase().includes(transcriptionSearchQuery.toLowerCase())
|
|
)
|
|
: [];
|
|
|
|
return (
|
|
<div className="flex flex-col h-full pb-16 md:pb-0">
|
|
<ChatHeader />
|
|
|
|
<ScrollArea className="flex-1">
|
|
<div className="max-w-4xl mx-auto p-3 md:p-6">
|
|
<Tabs defaultValue="transcription" className="space-y-4 md:space-y-6">
|
|
<TabsList className="grid w-full grid-cols-2 glass-effect text-xs md:text-sm">
|
|
<TabsTrigger value="transcription" className="text-xs md:text-sm">
|
|
Transcrição
|
|
</TabsTrigger>
|
|
<TabsTrigger value="history" className="text-xs md:text-sm">
|
|
Histórico
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="transcription" className="space-y-6">
|
|
<div className="glass-effect rounded-xl p-6 space-y-4">
|
|
<h3 className="text-lg font-semibold">
|
|
Converter Áudio em Texto
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
Envie um arquivo de áudio para transcrição (máx. 25MB)
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Formatos: {SUPPORTED_FORMATS.join(', ')}
|
|
</p>
|
|
|
|
<input
|
|
type="file"
|
|
id="audio-upload"
|
|
className="hidden"
|
|
accept={SUPPORTED_FORMATS.map(f => `.${f}`).join(',')}
|
|
onChange={handleFileSelect}
|
|
/>
|
|
|
|
<label
|
|
htmlFor="audio-upload"
|
|
className="border-2 border-dashed border-border rounded-xl p-12 text-center space-y-4 hover:border-primary/50 transition-colors cursor-pointer block"
|
|
>
|
|
<div className="w-16 h-16 mx-auto rounded-full bg-primary/10 flex items-center justify-center">
|
|
{selectedFile ? (
|
|
<FileAudio className="w-8 h-8 text-primary" />
|
|
) : (
|
|
<Upload className="w-8 h-8 text-primary" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<p className="font-medium">
|
|
{selectedFile ? selectedFile.name : "Clique para selecionar ou arraste o arquivo"}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{selectedFile
|
|
? `${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
|
|
: "Máximo 25 MB"
|
|
}
|
|
</p>
|
|
</div>
|
|
{!selectedFile && (
|
|
<Button variant="outline" className="gap-2" type="button">
|
|
<Upload className="w-4 h-4" />
|
|
Selecionar Arquivo
|
|
</Button>
|
|
)}
|
|
</label>
|
|
|
|
{isTranscribing && (
|
|
<div className="bg-muted/30 rounded-lg p-6 flex items-center justify-center gap-3">
|
|
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
|
<p className="text-sm font-medium">
|
|
Transcrevendo áudio...
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{lastTranscriptionResult && (
|
|
<div className="glass-effect rounded-xl p-6 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-semibold">{lastTranscriptionResult.audio_file_name}</h4>
|
|
<p className="text-xs text-muted-foreground">
|
|
Transcrito {new Date(lastTranscriptionResult.created_at).toLocaleTimeString('pt-BR')}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="gap-1"
|
|
onClick={() => handleCopyTranscription(lastTranscriptionResult.transcription_text)}
|
|
>
|
|
<Copy className="w-3 h-3" />
|
|
Copiar
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="gap-1"
|
|
onClick={() => handleDownloadTranscription(lastTranscriptionResult)}
|
|
>
|
|
<Download className="w-3 h-3" />
|
|
Baixar
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
className="gap-1"
|
|
onClick={handleDeleteTranscription}
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
Excluir
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="bg-muted/30 rounded-lg p-4">
|
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
|
{lastTranscriptionResult.transcription_text}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="history" className="space-y-4">
|
|
{/* Search and Filters */}
|
|
<div className="flex flex-col md:flex-row gap-3">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
value={transcriptionSearchQuery}
|
|
onChange={(e) => setTranscriptionSearchQuery(e.target.value)}
|
|
placeholder="Buscar por nome ou conteúdo..."
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
|
|
<SelectTrigger className="w-full md:w-[180px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="glass-effect bg-popover border-border z-50">
|
|
<SelectItem value="5">5 por página</SelectItem>
|
|
<SelectItem value="10">10 por página</SelectItem>
|
|
<SelectItem value="20">20 por página</SelectItem>
|
|
<SelectItem value="50">50 por página</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Loading State */}
|
|
{isLoadingTranscriptions ? (
|
|
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
|
|
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
|
<p className="text-muted-foreground">Carregando transcrições...</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{filteredTranscriptions.length === 0 ? (
|
|
<div className="glass-effect rounded-xl p-12 text-center">
|
|
<FileAudio className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
|
<p className="text-muted-foreground">
|
|
{transcriptionSearchQuery ? 'Nenhuma transcrição encontrada' : 'Nenhuma transcrição no histórico'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="space-y-3">
|
|
{filteredTranscriptions.map((item) => (
|
|
<div key={item.id} className="glass-effect rounded-lg p-4 space-y-2">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-medium truncate">{item.audio_file_name}</h4>
|
|
<p className="text-xs text-muted-foreground">
|
|
{new Date(item.created_at).toLocaleDateString('pt-BR')} às{' '}
|
|
{new Date(item.created_at).toLocaleTimeString('pt-BR')}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleCopyTranscription(item.transcription_text)}
|
|
>
|
|
<Copy className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleDownloadTranscription(item)}
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleDeleteTranscriptionFromHistory(item.id)}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
{item.transcription_text}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{!transcriptionSearchQuery && transcriptionHistory.length >= perPage && (
|
|
<div className="flex items-center justify-center gap-4 mt-6">
|
|
<Pagination>
|
|
<PaginationContent>
|
|
<PaginationItem>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
className="gap-1"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
<span className="hidden md:inline">Anterior</span>
|
|
</Button>
|
|
</PaginationItem>
|
|
|
|
<PaginationItem>
|
|
<span className="text-sm text-muted-foreground px-4">
|
|
Página {currentPage}
|
|
</span>
|
|
</PaginationItem>
|
|
|
|
<PaginationItem>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
disabled={transcriptionHistory.length < perPage}
|
|
className="gap-1"
|
|
>
|
|
<span className="hidden md:inline">Próxima</span>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</PaginationItem>
|
|
</PaginationContent>
|
|
</Pagination>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
};
|