Integrate actual S3 upload and OpenAI Whisper transcription in TranscribePage
This commit is contained in:
parent
7999a3fa04
commit
5295001a54
@ -15,35 +15,46 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
FileAudio
|
FileAudio,
|
||||||
|
Bot // For speaker identification (if available in transcription)
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { AxiosError } from 'axios'; // Import AxiosError for type checking
|
||||||
|
|
||||||
// Mock transcription data for demonstration
|
// Transcription Segment Type
|
||||||
const mockTranscript = [
|
interface TranscriptionSegment {
|
||||||
{ id: 1, start: 0, end: 5, speaker: "Speaker 1", text: "Welcome to the evidentiary hearing for Barden versus State Farm." },
|
id: number;
|
||||||
{ id: 2, start: 5, end: 12, speaker: "Speaker 2", text: "Thank you, Your Honor. We are here today to discuss the lack of personal jurisdiction as outlined in our recent motion." },
|
start: number;
|
||||||
{ id: 3, start: 12, end: 18, speaker: "Speaker 1", text: "Proceed. Please state your primary grounds for this challenge." },
|
end: number;
|
||||||
{ id: 4, start: 18, end: 25, speaker: "Speaker 2", text: "The defendant has no minimum contacts with the state of North Carolina, as required by the long-arm statute." },
|
speaker: string;
|
||||||
{ id: 5, start: 25, end: 32, speaker: "Speaker 3", text: "Objection, Your Honor. The defendant has maintained an office in Charlotte for over five years." },
|
text: string;
|
||||||
{ id: 6, start: 32, end: 40, speaker: "Speaker 1", text: "Overruled. I will allow the defense to finish their opening statement before hearing your rebuttal." },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
export default function TranscribePage() {
|
export default function TranscribePage() {
|
||||||
|
const router = useRouter();
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(40);
|
const [mediaDuration, setMediaDuration] = useState(0);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0); // Will be updated by polling
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [transcription, setTranscription] = useState<TranscriptionSegment[]>([]);
|
||||||
|
const [documentId, setDocumentId] = useState<string | null>(null);
|
||||||
|
const [mediaBlobUrl, setMediaBlobUrl] = useState<string | null>(null); // For uploaded media preview
|
||||||
|
const [transcriptionStatus, setTranscriptionStatus] = useState<string>('PENDING');
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||||
const activeLineRef = useRef<HTMLDivElement>(null);
|
const activeLineRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Auto-scroll logic
|
// Hardcoded matterId for now - MUST be replaced with dynamic value (e.g., from URL or user context)
|
||||||
|
// For initial testing, ensure you have a matter created in your DB for the user.
|
||||||
|
const MATTER_ID = 'your_matter_id_here';
|
||||||
|
|
||||||
|
// --- Auto-scroll and media player controls ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeLineRef.current && transcriptRef.current) {
|
if (activeLineRef.current && transcriptRef.current) {
|
||||||
activeLineRef.current.scrollIntoView({
|
activeLineRef.current.scrollIntoView({
|
||||||
@ -53,30 +64,6 @@ export default function TranscribePage() {
|
|||||||
}
|
}
|
||||||
}, [currentTime]);
|
}, [currentTime]);
|
||||||
|
|
||||||
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
setSelectedFile(file);
|
|
||||||
simulateUpload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const simulateUpload = () => {
|
|
||||||
setIsUploading(true);
|
|
||||||
let progress = 0;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
progress += 10;
|
|
||||||
setUploadProgress(progress);
|
|
||||||
if (progress >= 100) {
|
|
||||||
clearInterval(interval);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsUploading(false);
|
|
||||||
setUploadProgress(0);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
@ -87,6 +74,7 @@ export default function TranscribePage() {
|
|||||||
const handleTimeUpdate = () => {
|
const handleTimeUpdate = () => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
setCurrentTime(audioRef.current.currentTime);
|
setCurrentTime(audioRef.current.currentTime);
|
||||||
|
setMediaDuration(audioRef.current.duration);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -97,6 +85,134 @@ export default function TranscribePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Real Upload and Transcription Polling Logic ---
|
||||||
|
const getAuthToken = () => {
|
||||||
|
// TODO: Implement actual JWT token retrieval (e.g., from localStorage, context, or auth hook)
|
||||||
|
// For now, return a placeholder. This MUST be replaced with a real token.
|
||||||
|
return 'YOUR_JWT_TOKEN_HERE';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setSelectedFile(file);
|
||||||
|
setMediaBlobUrl(URL.createObjectURL(file)); // Create URL for local preview
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setTranscription([]);
|
||||||
|
setTranscriptionStatus('PENDING');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (token === 'YOUR_JWT_TOKEN_HERE') {
|
||||||
|
alert('Please replace YOUR_JWT_TOKEN_HERE with a real token in transcribe/page.tsx and MATTER_ID with a valid matter.');
|
||||||
|
setIsUploading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/documents/${MATTER_ID}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'File upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setDocumentId(data.id); // Set the document ID to start polling for transcription
|
||||||
|
// No longer simulating progress, actual progress will come from polling.
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
setIsUploading(false);
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
alert(`Upload failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Polling for Transcription Results
|
||||||
|
useEffect(() => {
|
||||||
|
let pollingInterval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const pollTranscription = async () => {
|
||||||
|
if (!documentId) return;
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (token === 'YOUR_JWT_TOKEN_HERE') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/documents/${documentId}/transcription`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
setTranscriptionStatus(data.transcriptionStatus);
|
||||||
|
// Simulate progress based on status (real backend would send progress %)
|
||||||
|
if (data.transcriptionStatus === 'PROCESSING') {
|
||||||
|
setUploadProgress(50);
|
||||||
|
} else if (data.transcriptionStatus === 'COMPLETED') {
|
||||||
|
setUploadProgress(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.transcriptionStatus === 'COMPLETED' && data.transcriptionText) {
|
||||||
|
const parsedTranscription = JSON.parse(data.transcriptionText);
|
||||||
|
// Assuming parsedTranscription.segments contains what we need from OpenAI verbose_json
|
||||||
|
setTranscription(parsedTranscription.segments.map((s: any, index: number) => ({
|
||||||
|
id: index, // Use index if OpenAI doesn't provide segment ID directly
|
||||||
|
start: s.start,
|
||||||
|
end: s.end,
|
||||||
|
speaker: s.speaker || `Speaker ${s.id || 0}`, // Whisper doesn't do speaker diarization by default, might need external lib
|
||||||
|
text: s.text.trim(),
|
||||||
|
})));
|
||||||
|
setMediaDuration(parsedTranscription.duration || audioRef.current?.duration || 0);
|
||||||
|
setIsUploading(false); // Stop progress once transcription is done
|
||||||
|
clearInterval(pollingInterval);
|
||||||
|
} else if (data.transcriptionStatus === 'FAILED') {
|
||||||
|
setIsUploading(false);
|
||||||
|
alert('Transcription failed.');
|
||||||
|
clearInterval(pollingInterval);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching transcription:', error);
|
||||||
|
setIsUploading(false);
|
||||||
|
alert('Error fetching transcription status.');
|
||||||
|
clearInterval(pollingInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (documentId && isUploading) {
|
||||||
|
pollingInterval = setInterval(pollTranscription, 3000); // Poll every 3 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => clearInterval(pollingInterval); // Cleanup interval
|
||||||
|
}, [documentId, isUploading]);
|
||||||
|
|
||||||
|
const displayStatusMessage = () => {
|
||||||
|
switch (transcriptionStatus) {
|
||||||
|
case 'PENDING':
|
||||||
|
return 'Waiting to start transcription...';
|
||||||
|
case 'PROCESSING':
|
||||||
|
return 'Transcribing in progress...';
|
||||||
|
case 'COMPLETED':
|
||||||
|
return 'Transcription complete!';
|
||||||
|
case 'FAILED':
|
||||||
|
return 'Transcription failed!';
|
||||||
|
default:
|
||||||
|
return 'Unknown status';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-12rem)] flex flex-col gap-6 -m-4">
|
<div className="h-[calc(100vh-12rem)] flex flex-col gap-6 -m-4">
|
||||||
<div className="flex justify-between items-center px-4">
|
<div className="flex justify-between items-center px-4">
|
||||||
@ -136,15 +252,17 @@ export default function TranscribePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isUploading && (
|
{isUploading && (transcriptionStatus !== 'COMPLETED') && (
|
||||||
<div className="mx-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 p-4 rounded-xl flex items-center gap-4">
|
<div className="mx-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 p-4 rounded-xl flex items-center gap-4">
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white">
|
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white">
|
||||||
<Upload size={20} className="animate-bounce" />
|
<Upload size={20} className="animate-bounce" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex justify-between items-center mb-1">
|
<div className="flex justify-between items-center mb-1">
|
||||||
<p className="text-sm font-bold text-blue-900 dark:text-blue-100">Processing: {selectedFile?.name}</p>
|
<p className="text-sm font-bold text-blue-900 dark:text-blue-100">
|
||||||
<p className="text-xs font-bold text-blue-600">{uploadProgress}%</p>
|
{selectedFile ? `Uploading & Processing: ${selectedFile.name}` : 'Processing file...'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-bold text-blue-600">{transcriptionStatus === 'COMPLETED' ? '100%' : uploadProgress > 0 ? `${uploadProgress}%` : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-blue-200 dark:bg-blue-800 h-2 rounded-full overflow-hidden">
|
<div className="w-full bg-blue-200 dark:bg-blue-800 h-2 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@ -152,8 +270,9 @@ export default function TranscribePage() {
|
|||||||
style={{ width: `${uploadProgress}%` }}
|
style={{ width: `${uploadProgress}%` }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-blue-700 dark:text-blue-200 mt-1">{displayStatusMessage()}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setIsUploading(false)} className="text-blue-400 hover:text-blue-600">
|
<button onClick={() => {setIsUploading(false); setTranscriptionStatus('PENDING');}} className="text-blue-400 hover:text-blue-600">
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -163,12 +282,26 @@ export default function TranscribePage() {
|
|||||||
{/* Left: Media Player & Controls */}
|
{/* Left: Media Player & Controls */}
|
||||||
<div className="w-1/3 flex flex-col gap-4">
|
<div className="w-1/3 flex flex-col gap-4">
|
||||||
<div className="bg-slate-900 rounded-2xl p-8 flex flex-col items-center justify-center text-white aspect-video relative overflow-hidden group">
|
<div className="bg-slate-900 rounded-2xl p-8 flex flex-col items-center justify-center text-white aspect-video relative overflow-hidden group">
|
||||||
{selectedFile ? (
|
{mediaBlobUrl ? (
|
||||||
<FileAudio size={64} className="text-blue-500 mb-4" />
|
<audio
|
||||||
|
controls
|
||||||
|
ref={audioRef}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={() => audioRef.current && setMediaDuration(audioRef.current.duration)}
|
||||||
|
src={mediaBlobUrl}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
playbackRate={playbackSpeed}
|
||||||
|
></audio>
|
||||||
) : (
|
) : (
|
||||||
<Volume2 size={64} className="text-blue-500/20 mb-4" />
|
<>
|
||||||
|
<Volume2 size={64} className="text-blue-500/20 mb-4" />
|
||||||
|
<p className="font-bold">Upload audio/video to begin</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<p className="font-bold truncate max-w-full px-4">{selectedFile ? selectedFile.name : 'Evidentiary_Hearing_022426.mp3'}</p>
|
<p className="font-bold truncate max-w-full px-4">{selectedFile ? selectedFile.name : 'No file selected'}</p>
|
||||||
<p className="text-xs text-slate-500">Matter: Barden v. State Farm</p>
|
<p className="text-xs text-slate-500">Matter: Barden v. State Farm</p>
|
||||||
|
|
||||||
{/* Minimalist Player UI Overlay */}
|
{/* Minimalist Player UI Overlay */}
|
||||||
@ -176,11 +309,11 @@ export default function TranscribePage() {
|
|||||||
<div className="w-full bg-white/20 h-1.5 rounded-full mb-4 cursor-pointer relative" onClick={(e) => {
|
<div className="w-full bg-white/20 h-1.5 rounded-full mb-4 cursor-pointer relative" onClick={(e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
seek((x / rect.width) * duration);
|
seek((x / rect.width) * mediaDuration);
|
||||||
}}>
|
}}>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 left-0 bg-blue-500 rounded-full"
|
className="absolute inset-y-0 left-0 bg-blue-500 rounded-full"
|
||||||
style={{ width: `${(currentTime / duration) * 100}%` }}
|
style={{ width: `${(currentTime / mediaDuration) * 100}%` }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -189,8 +322,14 @@ export default function TranscribePage() {
|
|||||||
<SkipBack size={20} className="cursor-pointer hover:text-blue-400" onClick={() => seek(currentTime - 5)} />
|
<SkipBack size={20} className="cursor-pointer hover:text-blue-400" onClick={() => seek(currentTime - 5)} />
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsPlaying(!isPlaying);
|
if (audioRef.current) {
|
||||||
// In real app: audioRef.current.play/pause
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center hover:bg-blue-700 transition-all"
|
className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center hover:bg-blue-700 transition-all"
|
||||||
>
|
>
|
||||||
@ -198,7 +337,7 @@ export default function TranscribePage() {
|
|||||||
</button>
|
</button>
|
||||||
<SkipForward size={20} className="cursor-pointer hover:text-blue-400" onClick={() => seek(currentTime + 5)} />
|
<SkipForward size={20} className="cursor-pointer hover:text-blue-400" onClick={() => seek(currentTime + 5)} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-mono">{formatTime(duration)}</span>
|
<span className="text-[10px] font-mono">{formatTime(mediaDuration)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -209,7 +348,10 @@ export default function TranscribePage() {
|
|||||||
<span className="text-sm font-medium">Playback Speed</span>
|
<span className="text-sm font-medium">Playback Speed</span>
|
||||||
<select
|
<select
|
||||||
value={playbackSpeed}
|
value={playbackSpeed}
|
||||||
onChange={(e) => setPlaybackSpeed(Number(e.target.value))}
|
onChange={(e) => {
|
||||||
|
setPlaybackSpeed(Number(e.target.value));
|
||||||
|
if(audioRef.current) audioRef.current.playbackRate = Number(e.target.value);
|
||||||
|
}}
|
||||||
className="bg-slate-50 dark:bg-slate-800 border-none rounded-lg text-sm font-bold p-2"
|
className="bg-slate-50 dark:bg-slate-800 border-none rounded-lg text-sm font-bold p-2"
|
||||||
>
|
>
|
||||||
<option value={0.5}>0.5x</option>
|
<option value={0.5}>0.5x</option>
|
||||||
@ -235,8 +377,8 @@ export default function TranscribePage() {
|
|||||||
<h4 className="font-bold text-sm">Transcript (Sync Active)</h4>
|
<h4 className="font-bold text-sm">Transcript (Sync Active)</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
<div className="flex gap-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||||
<span className="flex items-center gap-1"><User size={12} /> 3 Speakers Identified</span>
|
<span className="flex items-center gap-1"><User size={12} /> {new Set(transcription.map(t => t.speaker)).size} Speakers Identified</span>
|
||||||
<span className="flex items-center gap-1"><Clock size={12} /> 00:40 Total Duration</span>
|
<span className="flex items-center gap-1"><Clock size={12} /> {formatTime(mediaDuration)} Total Duration</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -244,47 +386,54 @@ export default function TranscribePage() {
|
|||||||
ref={transcriptRef}
|
ref={transcriptRef}
|
||||||
className="flex-1 overflow-y-auto p-8 space-y-8 scroll-smooth"
|
className="flex-1 overflow-y-auto p-8 space-y-8 scroll-smooth"
|
||||||
>
|
>
|
||||||
{mockTranscript.map((line) => {
|
{transcription.length > 0 ? (
|
||||||
const isActive = currentTime >= line.start && currentTime < line.end;
|
transcription.map((line) => {
|
||||||
const isMatch = searchQuery && line.text.toLowerCase().includes(searchQuery.toLowerCase());
|
const isActive = currentTime >= line.start && currentTime < line.end;
|
||||||
|
const isMatch = searchQuery && line.text.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={line.id}
|
key={line.id}
|
||||||
ref={isActive ? activeLineRef : null}
|
ref={isActive ? activeLineRef : null}
|
||||||
className={`flex gap-6 transition-all duration-300 rounded-xl p-4 ${
|
className={`flex gap-6 transition-all duration-300 rounded-xl p-4 ${
|
||||||
isActive ? 'bg-blue-50 dark:bg-blue-900/20 ring-1 ring-blue-100 dark:ring-blue-800 shadow-sm' : ''
|
isActive ? 'bg-blue-50 dark:bg-blue-900/20 ring-1 ring-blue-100 dark:ring-blue-800 shadow-sm' : ''
|
||||||
} ${isMatch ? 'bg-yellow-50 dark:bg-yellow-900/20' : ''}`}
|
} ${isMatch ? 'bg-yellow-50 dark:bg-yellow-900/20' : ''}`}
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => seek(line.start)}
|
|
||||||
className={`text-[11px] font-mono font-bold w-20 flex-shrink-0 transition-colors ${
|
|
||||||
isActive ? 'text-blue-600' : 'text-slate-400 hover:text-blue-500'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
[{formatTime(line.start)}]
|
<button
|
||||||
</button>
|
onClick={() => seek(line.start)}
|
||||||
<div className="space-y-1 flex-1">
|
className={`text-[11px] font-mono font-bold w-20 flex-shrink-0 transition-colors ${
|
||||||
<p className={`text-[10px] font-bold uppercase tracking-wider ${
|
isActive ? 'text-blue-600' : 'text-slate-400 hover:text-blue-500'
|
||||||
isActive ? 'text-blue-600' : 'text-slate-400'
|
}`}
|
||||||
}`}>
|
>
|
||||||
{line.speaker}
|
[{formatTime(line.start)}]
|
||||||
</p>
|
</button>
|
||||||
<p className={`text-sm leading-relaxed transition-colors ${
|
<div className="space-y-1 flex-1">
|
||||||
isActive ? 'text-slate-900 dark:text-white font-medium' : 'text-slate-600 dark:text-slate-400'
|
<p className={`text-[10px] font-bold uppercase tracking-wider ${
|
||||||
}`}>
|
isActive ? 'text-blue-600' : 'text-slate-400'
|
||||||
{line.text}
|
}`}>
|
||||||
</p>
|
{line.speaker}
|
||||||
|
</p>
|
||||||
|
<p className={`text-sm leading-relaxed transition-colors ${
|
||||||
|
isActive ? 'text-slate-900 dark:text-white font-medium' : 'text-slate-600 dark:text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{line.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})
|
||||||
})}
|
) : (
|
||||||
|
<div className="text-center text-slate-500 mt-12">
|
||||||
|
<p>Upload an audio or video file to get started with transcription.</p>
|
||||||
|
{isUploading && <p className="mt-2 text-blue-500">{displayStatusMessage()}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="p-4 bg-slate-50 dark:bg-slate-800/50 border-t border-slate-200 dark:border-slate-800">
|
<footer className="p-4 bg-slate-50 dark:bg-slate-800/50 border-t border-slate-200 dark:border-slate-800">
|
||||||
<div className="flex items-center justify-between text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
<div className="flex items-center justify-between text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||||
<p>Sync Latency: <100ms</p>
|
<p>Sync Latency: <100ms</p>
|
||||||
<p>Autosave Active: 12:45 PM</p>
|
<p>Autosave Active: {new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user