feat(transcription): v0.5.0 with global speaker management and trimming
- Backend: Added global speaker rename endpoint - Backend: Hardened JSON parsing and timestamp offsets - Frontend: Integrated Speaker Management Bar - Frontend: Added Trim Start/End (Scissors) and Single Line Delete - Frontend: Fixed various TypeScript and Syntax issues - Docs: Full documentation of v0.5.0 features
This commit is contained in:
@@ -1,85 +1,55 @@
|
||||
# Meeting Assistant (Transcription Tool)
|
||||
|
||||
**Version:** 0.4.0
|
||||
**Status:** Beta (Functional with Editing)
|
||||
**Version:** 0.5.0
|
||||
**Status:** Beta (Full Content Management)
|
||||
|
||||
Der **Meeting Assistant** ist ein lokaler Micro-Service zur Transkription und Analyse von Audio-Dateien (Meetings, Calls, Interviews). Er kombiniert die Datensicherheit einer lokalen Datenhaltung mit der Leistungsfähigkeit von Googles **Gemini 2.0 Flash** Modell für kostengünstige, hochqualitative Speech-to-Text Umwandlung.
|
||||
Der **Meeting Assistant** ist eine leistungsstarke Suite zur Transkription und Bearbeitung von Audio-Aufnahmen. Er kombiniert lokale FFmpeg-Verarbeitung mit der Gemini 2.0 Flash AI.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektur
|
||||
## 1. Architektur & Stack
|
||||
|
||||
Der Service folgt dem "Sidecar"-Pattern im Docker-Stack und ist vollständig in das Dashboard integriert.
|
||||
|
||||
* **Frontend:** React (Vite + Tailwind) unter `/tr/`.
|
||||
* **Backend:** FastAPI (Python) unter `/tr/api/`.
|
||||
* **Processing:**
|
||||
* **FFmpeg:** Zerlegt große Audio-Dateien (> 2 Stunden) in verarbeitbare 30-Minuten-Chunks.
|
||||
* **Gemini 2.0 Flash:** Führt die Transkription durch und liefert strukturiertes JSON (Sprecher, Zeitstempel, Text).
|
||||
* **SQLite:** Speichert Metadaten, Status und die bearbeitbaren JSON-Segmente.
|
||||
* **Storage:** Lokales Docker-Volume für Audio-Uploads.
|
||||
|
||||
### Datenfluss
|
||||
1. **Upload:** User lädt MP3 hoch -> Speicherung in `/app/uploads_audio`.
|
||||
2. **Chunking:** Backend startet Background-Task -> FFmpeg erstellt Segmente.
|
||||
3. **Transkription:** Loop über Chunks -> Upload zu Gemini -> JSON-Extraktion -> Offset-Berechnung -> DB-Speicherung.
|
||||
4. **Assemblierung:** Das Frontend lädt alle Chunks eines Meetings und stellt sie als eine durchgehende Liste dar.
|
||||
* **FFmpeg Engine:** Automatisches Splitting großer Dateien in 30-Minuten-Segmente.
|
||||
* **Gemini 2.0 Flash:** AI-Transkription mit Fokus auf JSON-Struktur (Sprecher, Timestamps).
|
||||
* **Structured Storage:** SQLite speichert jedes Segment als editierbares JSON-Array.
|
||||
* **Unified UI:** Das Frontend fügt alle Segmente zu einem nahtlosen Dokument zusammen.
|
||||
|
||||
---
|
||||
|
||||
## 2. API Endpunkte
|
||||
## 2. Key Features (v0.5.0)
|
||||
|
||||
Basis-URL: `/tr/api`
|
||||
### 🎙️ Intelligente Transkription
|
||||
* Unterstützt MP3/WAV bis 500MB.
|
||||
* Native Sprechererkennung und Zeitstempel-Normalisierung über Segmentgrenzen hinweg.
|
||||
|
||||
### 👥 Globales Sprecher-Management
|
||||
* **Speaker Bar:** Eine Übersicht aller im Dokument gefundenen Sprecher.
|
||||
* **Global Rename:** Mit einem Klick kann ein Sprecher (z.B. "Speaker A") im gesamten Dokument dauerhaft umbenannt werden (z.B. "Thomas").
|
||||
|
||||
### ✂️ Präzises Schneiden (Trimming)
|
||||
* **Trim Start:** Löscht alles *vor* einer ausgewählten Zeile (ideal zum Entfernen von Vorgesprächen).
|
||||
* **Trim End:** Löscht alles *nach* einer ausgewählten Zeile (entfernt Verabschiedungen).
|
||||
* **Single Line Delete:** Einzelne Zeilen oder Störgeräusche können individuell entfernt werden.
|
||||
|
||||
### 📝 Editor & Export
|
||||
* **Inline-Edit:** Jeder Textblock und jeder Sprechername kann durch direktes Anklicken korrigiert werden.
|
||||
* **Copy Full Transcript:** Kopiert das gesamte, bereinigte Transkript inkl. Timestamps in die Zwischenablage.
|
||||
|
||||
---
|
||||
|
||||
## 3. API Endpunkte
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
| :--- | :--- | :--- |
|
||||
| `GET` | `/meetings` | Liste aller Meetings inkl. Status. |
|
||||
| `POST` | `/upload` | Upload einer Audio-Datei (`multipart/form-data`). |
|
||||
| `GET` | `/meetings/{id}` | Lädt Meeting-Details inklusive aller Text-Chunks (JSON). |
|
||||
| `DELETE` | `/meetings/{id}` | Löscht ein Meeting inkl. Dateien komplett. |
|
||||
| `PUT` | `/chunks/{id}` | Aktualisiert den Inhalt (Text/Sprecher) eines spezifischen 30-Min-Chunks. |
|
||||
| `GET` | `/meetings` | Liste aller Meetings. |
|
||||
| `POST` | `/upload` | Audio-Upload & Prozess-Start. |
|
||||
| `POST` | `.../rename_speaker` | **Neu:** Globale Umbenennung in der DB. |
|
||||
| `PUT` | `/chunks/{id}` | Speichert manuelle Text-Korrekturen. |
|
||||
| `DELETE` | `/meetings/{id}` | Vollständiges Löschen. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenbank Schema (SQLite)
|
||||
## 4. Roadmap
|
||||
|
||||
Datei: `transcripts.db`
|
||||
|
||||
### `meetings`
|
||||
* `id`: PK
|
||||
* `title`, `status`, `duration_seconds`, `file_path`.
|
||||
|
||||
### `transcript_chunks`
|
||||
* `id`: PK
|
||||
* `meeting_id`: FK
|
||||
* `chunk_index`: 0, 1, 2...
|
||||
* `raw_text`: Backup des rohen Gemini-Outputs.
|
||||
* `json_content`: **JSON** (Editierbar). Struktur: `[{ "time": "MM:SS", "absolute_seconds": 120, "speaker": "A", "text": "..." }]`
|
||||
|
||||
---
|
||||
|
||||
## 4. Features & Bedienung
|
||||
|
||||
### Transkription
|
||||
* Upload von MP3/WAV Dateien (bis 500MB).
|
||||
* Automatische Erkennung von Sprechern (Speaker A, Speaker B).
|
||||
|
||||
### Editor-Modus (v0.4)
|
||||
* **Inline Editing:** Klicken Sie auf einen Sprechernamen oder Text, um ihn direkt zu bearbeiten. Änderungen werden sofort gespeichert.
|
||||
* **Zeilen Löschen:** Fahren Sie mit der Maus über eine Zeile und klicken Sie auf das rote "X", um irrelevante Teile (z.B. Smalltalk) zu entfernen.
|
||||
* **Sprecher-Aliasing (Ansicht):** Klicken Sie auf den blauen Sprechernamen ("Speaker A"), um ihn für die *aktuelle Sitzung* umzubenennen (z.B. in "Thomas"). *Hinweis: Dies ändert aktuell nur die Ansicht, nicht die Datenbank für alle Zeilen.*
|
||||
|
||||
---
|
||||
|
||||
## 5. Roadmap / Next Steps
|
||||
|
||||
* **v0.5: Global Rename:** Button "Alle 'Speaker A' dauerhaft in DB umbenennen".
|
||||
* **v0.6: AI Analysis:** "Erstelle Meeting Notes" Button basierend auf dem korrigierten Transkript.
|
||||
* **v0.7:** Export als Word/PDF.
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
* **Legacy Format:** Bei Dateien, die vor v0.3 hochgeladen wurden, erscheint ein Warnhinweis. Bitte neu hochladen, um die Editier-Funktionen zu nutzen.
|
||||
* **Upload bricht ab:** Prüfen Sie die Dateigröße (< 500MB).
|
||||
* **v0.6: AI Insights:** Extraktion von Aufgaben (Action Items) und Zusammenfassungen per Button.
|
||||
* **v0.7: Search:** Globale Suche über alle Transkripte hinweg.
|
||||
|
||||
@@ -97,6 +97,41 @@ async def upload_audio(
|
||||
|
||||
return meeting
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class RenameRequest(BaseModel):
|
||||
old_name: str
|
||||
new_name: str
|
||||
|
||||
@app.post("/api/meetings/{meeting_id}/rename_speaker")
|
||||
def rename_speaker_globally(meeting_id: int, payload: RenameRequest, db: Session = Depends(get_db)):
|
||||
meeting = db.query(Meeting).filter(Meeting.id == meeting_id).first()
|
||||
if not meeting:
|
||||
raise HTTPException(404, detail="Meeting not found")
|
||||
|
||||
count = 0
|
||||
# Iterate over all chunks directly from DB relation
|
||||
for chunk in meeting.chunks:
|
||||
if not chunk.json_content:
|
||||
continue
|
||||
|
||||
modified = False
|
||||
new_content = []
|
||||
for msg in chunk.json_content:
|
||||
if msg.get("speaker") == payload.old_name:
|
||||
msg["speaker"] = payload.new_name
|
||||
modified = True
|
||||
count += 1
|
||||
new_content.append(msg)
|
||||
|
||||
if modified:
|
||||
# Force update of the JSON field
|
||||
chunk.json_content = list(new_content)
|
||||
db.add(chunk)
|
||||
|
||||
db.commit()
|
||||
return {"status": "updated", "rows_affected": count}
|
||||
|
||||
@app.delete("/api/meetings/{meeting_id}")
|
||||
def delete_meeting(meeting_id: int, db: Session = Depends(get_db)):
|
||||
meeting = db.query(Meeting).filter(Meeting.id == meeting_id).first()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Upload, FileText, Clock, CheckCircle2, Loader2, AlertCircle, Trash2, ArrowLeft, Copy, User, X } from 'lucide-react'
|
||||
import { Upload, FileText, Clock, CheckCircle2, Loader2, AlertCircle, Trash2, ArrowLeft, Copy, Edit3, X, Scissors, Users } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const API_BASE = '/tr/api'
|
||||
@@ -11,6 +11,8 @@ interface TranscriptMessage {
|
||||
absolute_seconds: number
|
||||
speaker: string
|
||||
text: string
|
||||
_chunkId?: number // Virtual field for frontend mapping
|
||||
_idx?: number // Virtual field
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
@@ -44,6 +46,7 @@ export default function App() {
|
||||
// Editing State
|
||||
const [editingRow, setEditingRow] = useState<{chunkId: number, idx: number, field: 'speaker' | 'text'} | null>(null)
|
||||
const [editValue, setEditValue] = useState("")
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
|
||||
const fetchMeetings = async () => {
|
||||
try {
|
||||
@@ -99,7 +102,36 @@ export default function App() {
|
||||
} catch (e) { alert("Delete failed") }
|
||||
}
|
||||
|
||||
// --- EDITING LOGIC ---
|
||||
// --- GLOBAL SPEAKER MANAGEMENT ---
|
||||
|
||||
const getUniqueSpeakers = () => {
|
||||
if (!detailMeeting?.chunks) return []
|
||||
const speakers = new Set<string>()
|
||||
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 {
|
||||
@@ -110,6 +142,66 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -131,33 +223,16 @@ export default function App() {
|
||||
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
|
||||
// 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 (
|
||||
<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">
|
||||
@@ -172,7 +247,7 @@ export default function App() {
|
||||
<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">
|
||||
<header className="mb-6 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">
|
||||
@@ -191,6 +266,36 @@ export default function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* SPEAKER MANAGER BAR */}
|
||||
{uniqueSpeakers.length > 0 && (
|
||||
<div className="mb-6 bg-blue-50 dark:bg-blue-900/20 p-4 rounded-xl border border-blue-100 dark:border-blue-800 flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-blue-700 dark:text-blue-300">
|
||||
<Users className="h-4 w-4" /> Speakers found:
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{uniqueSpeakers.map(speaker => (
|
||||
<button
|
||||
key={speaker}
|
||||
onClick={() => handleGlobalRename(speaker)}
|
||||
className="px-3 py-1 bg-white dark:bg-slate-800 border border-blue-200 dark:border-blue-700 rounded-full text-xs font-medium text-slate-700 dark:text-slate-300 hover:border-blue-500 hover:text-blue-500 transition-colors flex items-center gap-1 shadow-sm"
|
||||
title="Click to rename globally"
|
||||
>
|
||||
{speaker} <Edit3 className="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProcessing && (
|
||||
<div className="fixed inset-0 bg-white/50 dark:bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-2xl flex items-center gap-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
<span className="font-medium">Updating transcript...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
@@ -199,17 +304,10 @@ export default function App() {
|
||||
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">
|
||||
<div key={uniqueIdx} className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex gap-4 group relative">
|
||||
{/* Time */}
|
||||
<div className="w-16 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">
|
||||
@@ -221,16 +319,14 @@ export default function App() {
|
||||
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)}
|
||||
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"
|
||||
onClick={() => { setEditingRow({chunkId: msg._chunkId!, idx: msg._idx!, field: 'speaker'}); setEditValue(msg.speaker); }}
|
||||
>
|
||||
<User className="h-3 w-3" />
|
||||
{msg.speaker}
|
||||
</div>
|
||||
)}
|
||||
@@ -244,21 +340,64 @@ export default function App() {
|
||||
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)}
|
||||
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); }}
|
||||
onClick={() => { setEditingRow({chunkId: msg._chunkId!, idx: msg._idx!, field: 'text'}); setEditValue(msg.text); }}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover Actions (Trim / Delete) */}
|
||||
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 flex gap-1 bg-white dark:bg-slate-900 border dark:border-slate-700 shadow-sm rounded-lg p-1 transition-all">
|
||||
<button
|
||||
onClick={() => handleTrim(msg, 'start')}
|
||||
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
|
||||
title="Trim Start: Delete everything BEFORE this line"
|
||||
>
|
||||
<Scissors className="h-3.5 w-3.5 rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTrim(msg, 'end')}
|
||||
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
|
||||
title="Trim End: Delete everything AFTER this line"
|
||||
>
|
||||
<Scissors className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="w-px bg-slate-200 dark:bg-slate-700 mx-1"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Single delete logic is redundant if we have trims, but nice to keep
|
||||
// Reuse update logic but filter out index
|
||||
// Simplified: Just use handleTrim but that's overkill
|
||||
// Let's implement quick single delete
|
||||
const cId = msg._chunkId!;
|
||||
const idx = msg._idx!;
|
||||
const newChunks = detailMeeting?.chunks?.map(c => {
|
||||
if (c.id === cId && c.json_content) {
|
||||
return { ...c, json_content: c.json_content.filter((_, i) => i !== idx) }
|
||||
}
|
||||
return c
|
||||
}) || [];
|
||||
setDetailMeeting(prev => prev ? ({ ...prev, chunks: newChunks }) : null);
|
||||
const updatedC = newChunks.find(c => c.id === cId);
|
||||
if(updatedC?.json_content) saveChunkUpdate(cId, updatedC.json_content);
|
||||
}}
|
||||
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
|
||||
title="Delete this line"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</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">
|
||||
@@ -277,7 +416,7 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
// List View
|
||||
// List View remains same
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user