feat(transcription): v0.4.0 with structured json, inline editing and deletion

- Backend: Switched prompt to JSON output for structured data
- Backend: Added PUT /chunks/{id} endpoint for persistence
- Backend: Fixed app.py imports and initialization logic
- Frontend: Complete rewrite for Unified View (flattened chunks)
- Frontend: Added Inline Editing (Text/Speaker) and Row Deletion
- Docs: Updated TRANSCRIPTION_TOOL.md with v0.4 features
This commit is contained in:
2026-01-24 20:43:33 +00:00
parent 0858df6f25
commit da00d461e1
5 changed files with 389 additions and 99 deletions

View File

@@ -1,10 +1,25 @@
import { useState, useEffect } from 'react'
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import { Upload, Mic, FileText, Clock, CheckCircle2, Loader2, AlertCircle, ChevronRight } from 'lucide-react'
import { Upload, FileText, Clock, CheckCircle2, Loader2, AlertCircle, Trash2, ArrowLeft, Copy, User, X } from 'lucide-react'
import clsx from 'clsx'
const API_BASE = '/tr/api'
interface TranscriptMessage {
time: string
display_time: string
absolute_seconds: number
speaker: string
text: string
}
interface Chunk {
id: number
chunk_index: number
raw_text: string
json_content: TranscriptMessage[] | null
}
interface Meeting {
id: number
title: string
@@ -12,13 +27,24 @@ interface Meeting {
date_recorded: string
duration_seconds?: number
created_at: string
chunks?: Chunk[]
}
export default function App() {
const [view, setView] = useState<'list' | 'detail'>('list')
const [selectedId, setSelectedId] = useState<number | null>(null)
const [meetings, setMeetings] = useState<Meeting[]>([])
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [detailMeeting, setDetailMeeting] = useState<Meeting | null>(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 fetchMeetings = async () => {
try {
const res = await axios.get(`${API_BASE}/meetings`)
@@ -28,94 +54,254 @@ export default function App() {
}
}
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(() => {
fetchMeetings()
const interval = setInterval(fetchMeetings, 5000) // Poll every 5s
return () => clearInterval(interval)
}, [])
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<HTMLInputElement>) => {
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") }
}
// --- EDITING LOGIC ---
const saveChunkUpdate = async (chunkId: number, newJson: TranscriptMessage[]) => {
try {
await axios.put(`${API_BASE}/chunks/${chunkId}`, { json_content: newJson })
} catch (e) {
setError("Upload failed. Make sure the file is not too large.")
} finally {
setUploading(false)
console.error("Failed to save chunk", e)
alert("Failed to save changes")
}
}
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)
}
const handleDeleteRow = async (chunkId: number, idx: number) => {
if (!confirm("Remove this line?")) return
if (!detailMeeting) return
const newChunks = detailMeeting.chunks!.map(c => {
if (c.id === chunkId && c.json_content) {
const newContent = c.json_content.filter((_, i) => i !== idx)
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)
}
}
// --- RENDER ---
if (view === 'detail') {
// Flatten for rendering but keep ref to chunkId
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) || []
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-200 font-sans">
<div className="max-w-4xl mx-auto px-4 py-8">
<button
onClick={() => { setView('list'); setSelectedId(null); setDetailMeeting(null); }}
className="flex items-center gap-2 text-slate-500 hover:text-blue-500 mb-6 transition-colors font-medium"
>
<ArrowLeft className="h-4 w-4" /> Back to List
</button>
{loadingDetail || !detailMeeting ? (
<div className="flex justify-center py-20"><Loader2 className="h-8 w-8 animate-spin text-blue-500" /></div>
) : (
<>
<header className="mb-8 border-b border-slate-200 dark:border-slate-800 pb-6 flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold mb-2">{detailMeeting.title}</h1>
<div className="flex items-center gap-4 text-sm text-slate-500">
<span className="flex items-center gap-1"><Clock className="h-4 w-4" /> {new Date(detailMeeting.created_at).toLocaleString()}</span>
<span className={clsx("px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide", detailMeeting.status === 'COMPLETED' ? "bg-green-100 text-green-700" : "bg-blue-100 text-blue-700")}>{detailMeeting.status}</span>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => navigator.clipboard.writeText(flatMessages.map(m => `[${m.display_time}] ${m.speaker}: ${m.text}`).join('\n'))}
className="text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 p-2 rounded" title="Copy Full Transcript"
>
<Copy className="h-5 w-5" />
</button>
<button onClick={(e) => handleDeleteMeeting(e, detailMeeting.id)} className="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 p-2 rounded"><Trash2 className="h-5 w-5" /></button>
</div>
</header>
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
{flatMessages.length > 0 ? (
<div className="divide-y divide-slate-100 dark:divide-slate-800">
{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 (
<div key={uniqueIdx} className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex gap-4 group">
{/* Time & Delete */}
<div className="w-20 pt-1 flex flex-col items-end gap-1 flex-shrink-0">
<span className="text-xs text-slate-400 font-mono">{msg.display_time || "00:00"}</span>
<button
onClick={() => handleDeleteRow(msg._chunkId, msg._idx)}
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 p-1 transition-all"
title="Delete Line"
>
<X className="h-3 w-3" />
</button>
</div>
<div className="flex-1 min-w-0">
{/* Speaker */}
<div className="mb-1 flex items-center gap-2">
{isEditingSpeaker ? (
<input
autoFocus
className="text-sm font-bold text-blue-600 dark:text-blue-400 bg-slate-100 dark:bg-slate-800 border rounded px-2 py-0.5 outline-none w-48"
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={() => handleUpdateRow(msg._chunkId, msg._idx, 'speaker', editValue)}
onKeyDown={e => e.key === 'Enter' && handleUpdateRow(msg._chunkId, msg._idx, 'speaker', editValue)}
/>
) : (
<div
className="font-bold text-sm text-blue-600 dark:text-blue-400 cursor-pointer hover:underline flex items-center gap-1 w-fit"
onClick={() => { setEditingRow({chunkId: msg._chunkId, idx: msg._idx, field: 'speaker'}); setEditValue(msg.speaker); }}
title="Click to rename THIS speaker occurrence"
>
<User className="h-3 w-3" />
{msg.speaker}
</div>
)}
</div>
{/* Text */}
<div>
{isEditingText ? (
<textarea
autoFocus
className="w-full text-slate-800 dark:text-slate-200 bg-slate-100 dark:bg-slate-800 border rounded px-2 py-1 outline-none min-h-[60px]"
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={() => handleUpdateRow(msg._chunkId, msg._idx, 'text', editValue)}
/>
) : (
<div
className="text-slate-800 dark:text-slate-200 leading-relaxed whitespace-pre-wrap cursor-text hover:bg-slate-100/50 dark:hover:bg-slate-800/30 rounded p-1 -ml-1 transition-colors"
onClick={() => { setEditingRow({chunkId: msg._chunkId, idx: msg._idx, field: 'text'}); setEditValue(msg.text); }}
>
{msg.text}
</div>
)}
</div>
</div>
</div>
)})}
</div> ) : (
<div className="text-center py-12">
{detailMeeting.chunks && detailMeeting.chunks.length > 0 && detailMeeting.chunks[0].raw_text ? (
<div className="p-8">
<p className="text-yellow-600 mb-2 font-medium">Legacy Format</p>
<p className="text-slate-500 text-sm mb-4">Re-upload file to enable editing.</p>
<div className="text-left font-mono text-xs overflow-auto max-h-96">{detailMeeting.chunks[0].raw_text}</div>
</div>
) : (<p className="text-slate-500">Processing...</p>)}
</div>
)}
</div>
</>
)}
</div>
</div>
)
}
// List View
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-200">
<div className="max-w-5xl mx-auto px-4 py-12">
<header className="flex items-center justify-between mb-12">
<div>
<h1 className="text-3xl font-bold tracking-tight">Meeting Assistant</h1>
<p className="text-slate-500 mt-2">Transcribe and analyze your meetings with Gemini 2.0</p>
</div>
<label className={clsx(
"flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-full font-semibold transition-all cursor-pointer shadow-lg shadow-blue-500/20",
uploading && "opacity-50 cursor-not-allowed"
)}>
<div><h1 className="text-3xl font-bold tracking-tight">Meeting Assistant</h1><p className="text-slate-500 mt-2">Transcribe and analyze your meetings with Gemini 2.0</p></div>
<label className={clsx("flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-full font-semibold transition-all cursor-pointer shadow-lg shadow-blue-500/20", uploading && "opacity-50 cursor-not-allowed")}>
{uploading ? <Loader2 className="h-5 w-5 animate-spin" /> : <Upload className="h-5 w-5" />}
{uploading ? "Uploading..." : "New Meeting"}
<input type="file" className="hidden" accept="audio/*" onChange={handleUpload} disabled={uploading} />
</label>
</header>
{error && (
<div className="mb-8 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-red-600 dark:text-red-400 flex items-center gap-3">
<AlertCircle className="h-5 w-5" />
{error}
</div>
)}
{error && <div className="mb-8 p-4 bg-red-50 text-red-600 rounded-xl flex items-center gap-3"><AlertCircle className="h-5 w-5" />{error}</div>}
<div className="grid gap-4">
{meetings.length === 0 ? (
<div className="text-center py-20 bg-white dark:bg-slate-900 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800">
<Mic className="h-12 w-12 mx-auto mb-4 text-slate-300" />
<p className="text-slate-500 font-medium">No meetings yet. Upload your first audio file.</p>
</div>
) : (
meetings.map(m => (
<div key={m.id} 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">
{meetings.map((m: Meeting) => (
<div key={m.id} onClick={() => { 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">
<div className="flex items-center gap-4">
<div className={clsx(
"p-3 rounded-xl",
m.status === 'COMPLETED' ? "bg-green-100 dark:bg-green-900/30 text-green-600" :
m.status === 'ERROR' ? "bg-red-100 dark:bg-red-900/30 text-red-600" :
"bg-blue-100 dark:bg-blue-900/30 text-blue-600 animate-pulse"
)}>
{m.status === 'COMPLETED' ? <CheckCircle2 className="h-6 w-6" /> : <FileText className="h-6 w-6" />}
</div>
<div>
<h3 className="font-bold text-lg leading-tight">{m.title}</h3>
<div className="flex items-center gap-4 mt-1 text-sm text-slate-500">
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5" /> {new Date(m.created_at).toLocaleDateString()}</span>
{m.duration_seconds && (
<span>{Math.round(m.duration_seconds / 60)} min</span>
)}
<span className={clsx(
"font-semibold uppercase tracking-wider text-[10px] px-2 py-0.5 rounded",
m.status === 'COMPLETED' ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-600"
)}>{m.status}</span>
</div>
</div>
<div className={clsx("p-3 rounded-xl", m.status === 'COMPLETED' ? "bg-green-100 text-green-600" : "bg-blue-100 text-blue-600")}>{m.status === 'COMPLETED' ? <CheckCircle2 className="h-6 w-6" /> : <FileText className="h-6 w-6" />}</div>
<div><h3 className="font-bold text-lg leading-tight">{m.title}</h3><div className="flex items-center gap-4 mt-1 text-sm text-slate-500"><span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5" /> {new Date(m.created_at).toLocaleDateString()}</span><span className={clsx("font-semibold uppercase tracking-wider text-[10px] px-2 py-0.5 rounded", m.status === 'COMPLETED' ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-600")}>{m.status}</span></div></div>
</div>
<ChevronRight className="h-6 w-6 text-slate-300 group-hover:text-blue-500 transition-colors" />
<button onClick={(e) => handleDeleteMeeting(e, m.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"><Trash2 className="h-5 w-5" /></button>
</div>
))
)}
))}
</div>
</div>
</div>
)
}
}