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 }) => (
)
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 && (
)}
{/* 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 */}
{/* Hover Actions (Trim / Delete) */}
)})}
) : (
{detailMeeting.chunks && detailMeeting.chunks.length > 0 && detailMeeting.chunks[0].raw_text ? (
Legacy Format
Re-upload file to enable editing.
{detailMeeting.chunks[0].raw_text}
) : (
Processing...
)}
)
)}
{activeTab === 'translation' && (
{translationResult?.result_text || "Loading translation..."}
)}
>
)}
)
}
// List View remains same
return (
{error &&
}
{meetings.map((m: Meeting) => (
{ setSelectedId(m.id); setView('detail'); }} className="group bg-white dark:bg-slate-900 p-6 rounded-2xl border border-slate-200 dark:border-slate-800 hover:shadow-xl transition-all flex items-center justify-between cursor-pointer">
{m.status === 'COMPLETED' ? : }
{m.title}
{new Date(m.created_at).toLocaleDateString()}{m.status}
))}
)
}