import React, { useState, useEffect } from 'react' import axios from 'axios' import { Upload, FileText, Clock, CheckCircle2, Loader2, AlertCircle, Trash2, ArrowLeft, Copy, Edit3, X, Scissors, Users, Wand2, Share2 } from 'lucide-react' import clsx from 'clsx' const API_BASE = '/tr/api' // --- INTERFACES --- interface AnalysisResult { id: number; prompt_key: string; result_text: string; created_at: string; } interface TranscriptMessage { time: string display_time: string absolute_seconds: number speaker: string text: string _chunkId?: number // Virtual field for frontend mapping _idx?: number // Virtual field } interface Chunk { id: number chunk_index: number raw_text: string json_content: TranscriptMessage[] | null } interface Meeting { id: number title: string status: string date_recorded: string duration_seconds?: number created_at: string chunks?: Chunk[] analysis_results?: AnalysisResult[] } // --- MODAL COMPONENT --- const InsightModal = ({ title, content, onClose, onCopy }: { title: string, content: string, onClose: () => void, onCopy: (text: string) => void }) => (

{title}

{content}
) export default function App() { const [view, setView] = useState<'list' | 'detail'>('list') const [selectedId, setSelectedId] = useState(null) const [meetings, setMeetings] = useState([]) const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) const [detailMeeting, setDetailMeeting] = useState(null) const [loadingDetail, setLoadingDetail] = useState(false) // Editing State const [editingRow, setEditingRow] = useState<{chunkId: number, idx: number, field: 'speaker' | 'text'} | null>(null) const [editValue, setEditValue] = useState("") const [isProcessing, setIsProcessing] = useState(false) // Insight State const [showInsightModal, setShowInsightModal] = useState(false) const [insightLoading, setInsightLoading] = useState(false) const [insightResult, setInsightResult] = useState('') const [insightTitle, setInsightTitle] = useState('') // Translation State const [translationResult, setTranslationResult] = useState(null) const [activeTab, setActiveTab] = useState<'transcript' | 'translation'>('transcript'); const fetchMeetings = async () => { try { const res = await axios.get(`${API_BASE}/meetings`) setMeetings(res.data) } catch (e) { console.error("Failed to fetch meetings", e) } } const fetchDetail = async (id: number) => { setLoadingDetail(true) try { const res = await axios.get(`${API_BASE}/meetings/${id}`) setDetailMeeting(res.data) } catch (e) { setError("Could not load details") } finally { setLoadingDetail(false) } } useEffect(() => { if (view === 'list') { fetchMeetings() const interval = setInterval(fetchMeetings, 5000) return () => clearInterval(interval) } else if (view === 'detail' && selectedId) { fetchDetail(selectedId) } }, [view, selectedId]) const handleUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return setUploading(true) setError(null) const formData = new FormData() formData.append('file', file) try { await axios.post(`${API_BASE}/upload`, formData) fetchMeetings() } catch (e) { setError("Upload failed.") } finally { setUploading(false) } } const handleDeleteMeeting = async (e: React.MouseEvent, id: number) => { e.stopPropagation() if (!confirm("Delete meeting permanently?")) return try { await axios.delete(`${API_BASE}/meetings/${id}`) if (selectedId === id) { setView('list'); setSelectedId(null); } fetchMeetings() } catch (e) { alert("Delete failed") } } // --- AI INSIGHTS --- const handleGenerateInsight = async (insightType: string, title: string) => { if (!detailMeeting) return; setInsightLoading(true); setInsightTitle(title); try { // First, check if result already exists const existing = detailMeeting.analysis_results?.find(r => r.prompt_key === insightType); if (existing) { setInsightResult(existing.result_text); setShowInsightModal(true); return; } // If not, generate it const res = await axios.post(`${API_BASE}/meetings/${detailMeeting.id}/insights`, { insight_type: insightType }); setInsightResult(res.data.result_text); setShowInsightModal(true); // Refresh detail view to get the new analysis result in the meeting object fetchDetail(detailMeeting.id); } catch(e) { console.error("Failed to generate insight", e); alert(`Error: Could not generate ${title}.`); } finally { setInsightLoading(false); } } const handleTranslate = async () => { if (!detailMeeting) return; setInsightLoading(true); try { // Use a consistent key for the translation type const translationType = 'translation_english'; // Check if result already exists in the main meeting object const existing = detailMeeting.analysis_results?.find(r => r.prompt_key === translationType); if (existing) { setTranslationResult(existing); // Here you might want to switch to a "Translation" tab or show a modal alert("Translation already exists and has been loaded."); return; } // If not, generate it const res = await axios.post(`${API_BASE}/meetings/${detailMeeting.id}/translate`, { target_language: 'English' }); // The API returns an AnalysisResult object, let's store it setTranslationResult(res.data); // Also refresh the main meeting detail to include this new result for persistence fetchDetail(detailMeeting.id); setActiveTab('translation'); alert("Translation successful!"); } catch(e) { console.error("Failed to generate translation", e); alert(`Error: Could not translate.`); } finally { setInsightLoading(false); } } // --- GLOBAL SPEAKER MANAGEMENT --- const getUniqueSpeakers = () => { if (!detailMeeting?.chunks) return [] const speakers = new Set() detailMeeting.chunks.forEach(c => { c.json_content?.forEach(m => speakers.add(m.speaker)) }) return Array.from(speakers).sort() } const handleGlobalRename = async (oldName: string) => { const newName = prompt(`Rename speaker '${oldName}' globally to:`, oldName) if (!newName || newName === oldName || !detailMeeting) return setIsProcessing(true) try { await axios.post(`${API_BASE}/meetings/${detailMeeting.id}/rename_speaker`, { old_name: oldName, new_name: newName }) fetchDetail(detailMeeting.id) } catch (e) { alert("Global rename failed") } finally { setIsProcessing(false) } } // --- TRIMMING & EDITING --- const saveChunkUpdate = async (chunkId: number, newJson: TranscriptMessage[]) => { try { await axios.put(`${API_BASE}/chunks/${chunkId}`, { json_content: newJson }) } catch (e) { console.error("Failed to save chunk", e) alert("Failed to save changes") } } // Trim Logic: Cut everything BEFORE or AFTER the target message const handleTrim = async (targetMsg: TranscriptMessage, mode: 'start' | 'end') => { if (!detailMeeting || !detailMeeting.chunks) return if (!confirm(mode === 'start' ? "Delete EVERYTHING before this line?" : "Delete EVERYTHING after this line?")) return setIsProcessing(true) // We need to iterate all chunks and decide what to do const updates = [] for (const chunk of detailMeeting.chunks) { if (!chunk.json_content) continue let newContent = [...chunk.json_content] let changed = false if (mode === 'start') { // Cut BEFORE if (chunk.chunk_index < (detailMeeting.chunks.find(c => c.id === targetMsg._chunkId)?.chunk_index || 0)) { // This entire chunk is BEFORE the target chunk -> Empty it if (newContent.length > 0) { newContent = [] changed = true } } else if (chunk.id === targetMsg._chunkId) { // This is the target chunk -> Slice content const cutIdx = newContent.findIndex(m => m.absolute_seconds === targetMsg.absolute_seconds && m.text === targetMsg.text) if (cutIdx > 0) { newContent = newContent.slice(cutIdx) changed = true } } } else { // Cut AFTER if (chunk.chunk_index > (detailMeeting.chunks.find(c => c.id === targetMsg._chunkId)?.chunk_index || 0)) { // This entire chunk is AFTER -> Empty it if (newContent.length > 0) { newContent = [] changed = true } } else if (chunk.id === targetMsg._chunkId) { // Target chunk const cutIdx = newContent.findIndex(m => m.absolute_seconds === targetMsg.absolute_seconds && m.text === targetMsg.text) if (cutIdx !== -1 && cutIdx < newContent.length - 1) { newContent = newContent.slice(0, cutIdx + 1) changed = true } } } if (changed) { updates.push(saveChunkUpdate(chunk.id, newContent)) } } await Promise.all(updates) await fetchDetail(detailMeeting.id) setIsProcessing(false) } const handleUpdateRow = async (chunkId: number, idx: number, field: 'speaker' | 'text', value: string) => { if (!detailMeeting) return const newChunks = detailMeeting.chunks!.map(c => { if (c.id === chunkId && c.json_content) { const newContent = [...c.json_content] newContent[idx] = { ...newContent[idx], [field]: value } return { ...c, json_content: newContent } } return c }) setDetailMeeting({ ...detailMeeting, chunks: newChunks }) const updatedChunk = newChunks.find(c => c.id === chunkId) if (updatedChunk?.json_content) { await saveChunkUpdate(chunkId, updatedChunk.json_content) } setEditingRow(null) } // --- RENDER --- if (view === 'detail') { // Flatten messages const flatMessages = detailMeeting?.chunks?.flatMap(c => (c.json_content || []).map((msg, idx) => ({ ...msg, _chunkId: c.id, _idx: idx })) ).sort((a,b) => a.absolute_seconds - b.absolute_seconds) || [] const uniqueSpeakers = getUniqueSpeakers() return (
{showInsightModal && setShowInsightModal(false)} onCopy={text => navigator.clipboard.writeText(text)} />}
{loadingDetail || !detailMeeting ? (
) : ( <>

{detailMeeting.title}

{new Date(detailMeeting.created_at).toLocaleString()} {detailMeeting.status}
{/* SPEAKER MANAGER BAR */} {uniqueSpeakers.length > 0 && (
Speakers found:
{uniqueSpeakers.map(speaker => ( ))}
)}

AI Tools

{insightLoading && }
{isProcessing && (
Updating transcript...
)}
{/* TAB Navigation */}
{translationResult && ( )}
{/* Conditional Content */} {activeTab === 'transcript' && ( flatMessages.length > 0 ? (
{flatMessages.map((msg, uniqueIdx) => { const isEditingSpeaker = editingRow?.chunkId === msg._chunkId && editingRow?.idx === msg._idx && editingRow?.field === 'speaker'; const isEditingText = editingRow?.chunkId === msg._chunkId && editingRow?.idx === msg._idx && editingRow?.field === 'text'; return (
{/* Time */}
{msg.display_time || "00:00"}
{/* Speaker */}
{isEditingSpeaker ? ( setEditValue(e.target.value)} onBlur={() => handleUpdateRow(msg._chunkId!, msg._idx!, 'speaker', editValue)} onKeyDown={e => e.key === 'Enter' && handleUpdateRow(msg._chunkId!, msg._idx!, 'speaker', editValue)} /> ) : (
{ setEditingRow({chunkId: msg._chunkId!, idx: msg._idx!, field: 'speaker'}); setEditValue(msg.speaker); }} > {msg.speaker}
)}
{/* Text */}
{isEditingText ? (