mirror of
https://github.com/rishikanthc/Scriberr.git
synced 2026-06-28 14:55:46 +00:00
ability to highlight and annotate transcript with notes
This commit is contained in:
168
internal/api/notes_handlers.go
Normal file
168
internal/api/notes_handlers.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"scriberr/internal/database"
|
||||
"scriberr/internal/models"
|
||||
)
|
||||
|
||||
// NoteCreateRequest is the payload for creating a note
|
||||
type NoteCreateRequest struct {
|
||||
StartWordIndex int `json:"start_word_index" binding:"required,min=0"`
|
||||
EndWordIndex int `json:"end_word_index" binding:"required,min=0"`
|
||||
StartTime float64 `json:"start_time" binding:"required"`
|
||||
EndTime float64 `json:"end_time" binding:"required"`
|
||||
Quote string `json:"quote" binding:"required,min=1"`
|
||||
Content string `json:"content" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// NoteUpdateRequest updates content of a note
|
||||
type NoteUpdateRequest struct {
|
||||
Content string `json:"content" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// ListNotes returns all notes for a transcription
|
||||
func (h *Handler) ListNotes(c *gin.Context) {
|
||||
transcriptionID := c.Param("id")
|
||||
if transcriptionID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Transcription ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure transcription exists
|
||||
var job models.TranscriptionJob
|
||||
if err := database.DB.Where("id = ?", transcriptionID).First(&job).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transcription"})
|
||||
return
|
||||
}
|
||||
|
||||
var notes []models.Note
|
||||
if err := database.DB.Where("transcription_id = ?", transcriptionID).
|
||||
Order("start_time ASC, created_at ASC").Find(¬es).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notes"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, notes)
|
||||
}
|
||||
|
||||
// CreateNote stores a new note for a transcription
|
||||
func (h *Handler) CreateNote(c *gin.Context) {
|
||||
transcriptionID := c.Param("id")
|
||||
if transcriptionID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Transcription ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req NoteCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.EndWordIndex < req.StartWordIndex {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "end_word_index must be >= start_word_index"})
|
||||
return
|
||||
}
|
||||
if req.EndTime < req.StartTime {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "end_time must be >= start_time"})
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure transcription exists
|
||||
var job models.TranscriptionJob
|
||||
if err := database.DB.Where("id = ?", transcriptionID).First(&job).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transcription"})
|
||||
return
|
||||
}
|
||||
|
||||
n := models.Note{
|
||||
ID: uuid.New().String(),
|
||||
TranscriptionID: transcriptionID,
|
||||
StartWordIndex: req.StartWordIndex,
|
||||
EndWordIndex: req.EndWordIndex,
|
||||
StartTime: req.StartTime,
|
||||
EndTime: req.EndTime,
|
||||
Quote: req.Quote,
|
||||
Content: req.Content,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&n).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create note"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, n)
|
||||
}
|
||||
|
||||
// GetNote returns a note by ID
|
||||
func (h *Handler) GetNote(c *gin.Context) {
|
||||
noteID := c.Param("note_id")
|
||||
var n models.Note
|
||||
if err := database.DB.Where("id = ?", noteID).First(&n).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Note not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch note"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, n)
|
||||
}
|
||||
|
||||
// UpdateNote updates the content of an existing note
|
||||
func (h *Handler) UpdateNote(c *gin.Context) {
|
||||
noteID := c.Param("note_id")
|
||||
var req NoteUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var n models.Note
|
||||
if err := database.DB.Where("id = ?", noteID).First(&n).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Note not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch note"})
|
||||
return
|
||||
}
|
||||
|
||||
n.Content = req.Content
|
||||
n.UpdatedAt = time.Now()
|
||||
|
||||
if err := database.DB.Save(&n).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update note"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, n)
|
||||
}
|
||||
|
||||
// DeleteNote removes a note by ID
|
||||
func (h *Handler) DeleteNote(c *gin.Context) {
|
||||
noteID := c.Param("note_id")
|
||||
if err := database.DB.Delete(&models.Note{}, "id = ?", noteID).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete note"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ func SetupRoutes(handler *Handler, authService *auth.AuthService) *gin.Engine {
|
||||
transcription.DELETE("/:id", handler.DeleteJob)
|
||||
transcription.GET("/list", handler.ListJobs)
|
||||
transcription.GET("/models", handler.GetSupportedModels)
|
||||
// Notes for a transcription
|
||||
transcription.GET("/:id/notes", handler.ListNotes)
|
||||
transcription.POST("/:id/notes", handler.CreateNote)
|
||||
|
||||
// Quick transcription endpoints
|
||||
transcription.POST("/quick", handler.SubmitQuickTranscription)
|
||||
@@ -128,6 +131,15 @@ func SetupRoutes(handler *Handler, authService *auth.AuthService) *gin.Engine {
|
||||
chat.POST("/sessions/:session_id/title/auto", handler.AutoGenerateChatTitle)
|
||||
chat.DELETE("/sessions/:session_id", handler.DeleteChatSession)
|
||||
}
|
||||
|
||||
// Notes routes (require authentication)
|
||||
notes := v1.Group("/notes")
|
||||
notes.Use(middleware.AuthMiddleware(authService))
|
||||
{
|
||||
notes.GET("/:note_id", handler.GetNote)
|
||||
notes.PUT("/:note_id", handler.UpdateNote)
|
||||
notes.DELETE("/:note_id", handler.DeleteNote)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up static file serving for React app
|
||||
|
||||
@@ -40,6 +40,7 @@ func Initialize(dbPath string) error {
|
||||
&models.LLMConfig{},
|
||||
&models.ChatSession{},
|
||||
&models.ChatMessage{},
|
||||
&models.Note{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to auto migrate: %v", err)
|
||||
}
|
||||
@@ -88,4 +89,4 @@ func Close() error {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
29
internal/models/note.go
Normal file
29
internal/models/note.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Note represents an annotation attached to a transcription
|
||||
type Note struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:varchar(36)"`
|
||||
TranscriptionID string `json:"transcription_id" gorm:"type:varchar(36);not null;index"`
|
||||
|
||||
// Indexed selection into transcript by word positions
|
||||
StartWordIndex int `json:"start_word_index" gorm:"type:int;not null"`
|
||||
EndWordIndex int `json:"end_word_index" gorm:"type:int;not null"`
|
||||
|
||||
// Time bounds for the selection (in seconds)
|
||||
StartTime float64 `json:"start_time" gorm:"type:real;not null"`
|
||||
EndTime float64 `json:"end_time" gorm:"type:real;not null"`
|
||||
|
||||
// The exact quoted text chosen by the user
|
||||
Quote string `json:"quote" gorm:"type:text;not null"`
|
||||
|
||||
// The user's note content (markdown/plain)
|
||||
Content string `json:"content" gorm:"type:text;not null"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
9
internal/web/dist/assets/index-Ccgo9Msa.css
vendored
Normal file
9
internal/web/dist/assets/index-Ccgo9Msa.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
internal/web/dist/assets/index-wNXmT10W.css
vendored
9
internal/web/dist/assets/index-wNXmT10W.css
vendored
File diff suppressed because one or more lines are too long
4
internal/web/dist/index.html
vendored
4
internal/web/dist/index.html
vendored
@@ -8,8 +8,8 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Fira+Code:wght@300..700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-Byxm2fDd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-wNXmT10W.css">
|
||||
<script type="module" crossorigin src="/assets/index-DbDjjFKg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Ccgo9Msa.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ArrowLeft, Play, Pause, List, AlignLeft, MessageCircle, Download, FileText, FileJson, FileImage, Check } from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowLeft, Play, Pause, List, AlignLeft, MessageCircle, Download, FileText, FileJson, FileImage, Check, StickyNote, Plus } from "lucide-react";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
@@ -23,6 +24,8 @@ import { useTheme } from "../contexts/ThemeContext";
|
||||
import { ThemeSwitcher } from "./ThemeSwitcher";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { ChatInterface } from "./ChatInterface";
|
||||
import type { Note } from "../types/note";
|
||||
import { NotesSidebar } from "./NotesSidebar";
|
||||
|
||||
interface AudioFile {
|
||||
id: string;
|
||||
@@ -78,10 +81,20 @@ export function AudioDetailView({ audioId }: AudioDetailViewProps) {
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const highlightedWordRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("AudioDetailView mounted, audioId:", audioId);
|
||||
fetchAudioDetails();
|
||||
}, [audioId]);
|
||||
// Notes state
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [notesOpen, setNotesOpen] = useState(false);
|
||||
const [showSelectionMenu, setShowSelectionMenu] = useState(false);
|
||||
const [pendingSelection, setPendingSelection] = useState<{startIdx:number; endIdx:number; startTime:number; endTime:number; quote:string} | null>(null);
|
||||
const [newNoteContent, setNewNoteContent] = useState("");
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [selectionViewportPos, setSelectionViewportPos] = useState<{x:number,y:number}>({x:0,y:0});
|
||||
|
||||
useEffect(() => {
|
||||
console.log("AudioDetailView mounted, audioId:", audioId);
|
||||
fetchAudioDetails();
|
||||
fetchNotes();
|
||||
}, [audioId]);
|
||||
|
||||
// Initialize WaveSurfer when audioFile is available - with proper DOM timing
|
||||
useEffect(() => {
|
||||
@@ -250,6 +263,16 @@ export function AudioDetailView({ audioId }: AudioDetailViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNotes = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/transcription/${audioId}/notes`, { headers: { ...getAuthHeaders() }});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNotes(data);
|
||||
}
|
||||
} catch (e) { console.error("Failed to fetch notes", e); }
|
||||
};
|
||||
|
||||
const initializeWaveSurfer = async () => {
|
||||
if (!waveformRef.current || !audioFile) return;
|
||||
|
||||
@@ -336,26 +359,96 @@ export function AudioDetailView({ audioId }: AudioDetailViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlayPause = () => {
|
||||
if (wavesurferRef.current) {
|
||||
wavesurferRef.current.playPause();
|
||||
}
|
||||
};
|
||||
const togglePlayPause = () => {
|
||||
if (wavesurferRef.current) {
|
||||
wavesurferRef.current.playPause();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate({ path: "home" });
|
||||
};
|
||||
const handleBack = () => {
|
||||
navigate({ path: "home" });
|
||||
};
|
||||
|
||||
// Handle word click to seek to that time
|
||||
const handleWordClick = (word: WordSegment) => {
|
||||
if (wavesurferRef.current) {
|
||||
const duration = wavesurferRef.current.getDuration();
|
||||
const progress = word.start / duration;
|
||||
wavesurferRef.current.seekTo(progress);
|
||||
// Manually update current time to ensure highlighting syncs immediately
|
||||
setCurrentTime(word.start);
|
||||
}
|
||||
};
|
||||
// Selection handling for annotation
|
||||
useEffect(() => {
|
||||
const el = transcriptRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onMouseUp = () => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed) { setShowSelectionMenu(false); setShowEditor(false); return; }
|
||||
const anchor = sel.anchorNode as HTMLElement | null;
|
||||
const focus = sel.focusNode as HTMLElement | null;
|
||||
if (!anchor || !focus) return;
|
||||
const aSpan = (anchor.nodeType === 3 ? anchor.parentElement : anchor) as HTMLElement;
|
||||
const fSpan = (focus.nodeType === 3 ? focus.parentElement : focus) as HTMLElement;
|
||||
if (!aSpan || !fSpan) return;
|
||||
const aIdx = aSpan.closest('span[data-word-index]') as HTMLElement | null;
|
||||
const fIdx = fSpan.closest('span[data-word-index]') as HTMLElement | null;
|
||||
if (!aIdx || !fIdx) { setShowSelectionMenu(false); return; }
|
||||
const startIdx = Math.min(Number(aIdx.dataset.wordIndex), Number(fIdx.dataset.wordIndex));
|
||||
const endIdx = Math.max(Number(aIdx.dataset.wordIndex), Number(fIdx.dataset.wordIndex));
|
||||
if (!transcript?.word_segments || endIdx < startIdx) { setShowSelectionMenu(false); return; }
|
||||
const startTime = transcript.word_segments[startIdx]?.start ?? 0;
|
||||
const endTime = transcript.word_segments[endIdx]?.end ?? startTime;
|
||||
const quote = transcript.word_segments.slice(startIdx, endIdx + 1).map(w => w.word).join(" ");
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
// Use viewport coords for portal positioning
|
||||
setSelectionViewportPos({ x: rect.left + rect.width / 2, y: rect.top - 10 });
|
||||
setPendingSelection({ startIdx, endIdx, startTime, endTime, quote });
|
||||
setShowSelectionMenu(true);
|
||||
};
|
||||
|
||||
el.addEventListener('mouseup', onMouseUp);
|
||||
return () => el.removeEventListener('mouseup', onMouseUp);
|
||||
}, [transcript, transcriptMode]);
|
||||
|
||||
const openEditorForSelection = () => {
|
||||
setShowEditor(true);
|
||||
setShowSelectionMenu(false);
|
||||
setNewNoteContent("");
|
||||
};
|
||||
|
||||
const saveNewNote = async () => {
|
||||
if (!pendingSelection) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/transcription/${audioId}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({
|
||||
start_word_index: pendingSelection.startIdx,
|
||||
end_word_index: pendingSelection.endIdx,
|
||||
start_time: pendingSelection.startTime,
|
||||
end_time: pendingSelection.endTime,
|
||||
quote: pendingSelection.quote,
|
||||
content: newNoteContent.trim() || pendingSelection.quote,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const created = await res.json();
|
||||
setNotes(prev => [...prev, created]);
|
||||
setShowEditor(false);
|
||||
setPendingSelection(null);
|
||||
const sel = window.getSelection(); sel?.removeAllRanges();
|
||||
}
|
||||
} catch (e) { console.error('Failed to create note', e); }
|
||||
};
|
||||
|
||||
const updateNote = async (id: string, newContent: string) => {
|
||||
await fetch(`/api/v1/notes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ content: newContent }),
|
||||
});
|
||||
setNotes(prev => prev.map(n => n.id === id ? { ...n, content: newContent } : n));
|
||||
};
|
||||
|
||||
const deleteNote = async (id: string) => {
|
||||
await fetch(`/api/v1/notes/${id}`, { method: 'DELETE', headers: { ...getAuthHeaders() }});
|
||||
setNotes(prev => prev.filter(n => n.id !== id));
|
||||
};
|
||||
|
||||
// Render transcript with word-level highlighting
|
||||
const renderHighlightedTranscript = () => {
|
||||
@@ -365,23 +458,26 @@ export function AudioDetailView({ audioId }: AudioDetailViewProps) {
|
||||
|
||||
return transcript.word_segments.map((word, index) => {
|
||||
const isHighlighted = index === currentWordIndex;
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
ref={isHighlighted ? highlightedWordRef : undefined}
|
||||
onClick={() => handleWordClick(word)}
|
||||
className={`cursor-pointer transition-colors duration-150 hover:bg-blue-100 dark:hover:bg-blue-800 inline-block ${
|
||||
isHighlighted
|
||||
? 'bg-yellow-300 dark:bg-yellow-500 dark:text-black px-1 rounded'
|
||||
: 'px-0.5'
|
||||
}`}
|
||||
title={`${formatTimestamp(word.start)} - Click to seek`}
|
||||
>
|
||||
{word.word}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
const isAnnotated = notes.some(n => index >= n.start_word_index && index <= n.end_word_index);
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
ref={isHighlighted ? highlightedWordRef : undefined}
|
||||
data-word-index={index}
|
||||
data-word={word.word}
|
||||
data-start={word.start}
|
||||
data-end={word.end}
|
||||
className={`cursor-text transition-colors duration-150 hover:bg-blue-100 dark:hover:bg-blue-800 inline ${
|
||||
isHighlighted
|
||||
? 'bg-yellow-300 dark:bg-yellow-500 dark:text-black px-1 rounded'
|
||||
: isAnnotated ? 'bg-amber-100/70 dark:bg-amber-800/40 px-0.5 rounded' : 'px-0.5'
|
||||
}`}
|
||||
>
|
||||
{word.word}{" "}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Render segment with word-level highlighting for expanded view
|
||||
const renderSegmentWithHighlighting = (segment: any) => {
|
||||
@@ -401,23 +497,26 @@ export function AudioDetailView({ audioId }: AudioDetailViewProps) {
|
||||
return segmentWords.map((word, index) => {
|
||||
const globalIndex = transcript.word_segments?.findIndex(w => w === word) ?? -1;
|
||||
const isHighlighted = globalIndex === currentWordIndex;
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
ref={isHighlighted ? highlightedWordRef : undefined}
|
||||
onClick={() => handleWordClick(word)}
|
||||
className={`cursor-pointer transition-colors duration-150 hover:bg-blue-100 dark:hover:bg-blue-800 inline-block ${
|
||||
isHighlighted
|
||||
? 'bg-yellow-300 dark:bg-yellow-500 dark:text-black px-1 rounded'
|
||||
: 'px-0.5'
|
||||
}`}
|
||||
title={`${formatTimestamp(word.start)} - Click to seek`}
|
||||
>
|
||||
{word.word}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
const isAnnotated = notes.some(n => globalIndex >= n.start_word_index && globalIndex <= n.end_word_index);
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
ref={isHighlighted ? highlightedWordRef : undefined}
|
||||
data-word-index={globalIndex}
|
||||
data-word={word.word}
|
||||
data-start={word.start}
|
||||
data-end={word.end}
|
||||
className={`cursor-text transition-colors duration-150 hover:bg-blue-100 dark:hover:bg-blue-800 inline ${
|
||||
isHighlighted
|
||||
? 'bg-yellow-300 dark:bg-yellow-500 dark:text-black px-1 rounded'
|
||||
: isAnnotated ? 'bg-amber-100/70 dark:bg-amber-800/40 px-0.5 rounded' : 'px-0.5'
|
||||
}`}
|
||||
>
|
||||
{word.word}{" "}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getFileName = (audioPath: string) => {
|
||||
const parts = audioPath.split("/");
|
||||
@@ -597,7 +696,8 @@ export function AudioDetailView({ audioId }: AudioDetailViewProps) {
|
||||
className="h-4 bg-gray-200 dark:bg-gray-600 rounded"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
{/* Selection bubble and editor moved to portal */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -673,7 +773,6 @@ export function AudioDetailView({ audioId }: AudioDetailViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript Section */}
|
||||
{audioFile.status === "completed" && transcript && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-3 sm:mb-6">
|
||||
@@ -748,6 +847,19 @@ export function AudioDetailView({ audioId }: AudioDetailViewProps) {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setNotesOpen(v => !v)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
title="Toggle notes"
|
||||
>
|
||||
<StickyNote className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Notes</span>
|
||||
<span className="ml-1 text-xs rounded-full px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700">{notes.length}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -755,36 +867,38 @@ export function AudioDetailView({ audioId }: AudioDetailViewProps) {
|
||||
{viewMode === "transcript" ? (
|
||||
<div className="relative overflow-hidden">
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
transcriptMode === "compact"
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 -translate-y-4 absolute inset-0"
|
||||
}`}
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
transcriptMode === "compact"
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 -translate-y-4 absolute inset-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{transcriptMode === "compact" && (
|
||||
<div
|
||||
ref={transcriptRef}
|
||||
className="prose prose-gray dark:prose-invert max-w-none"
|
||||
>
|
||||
<p className="text-gray-700 dark:text-gray-300 leading-relaxed break-words">
|
||||
{renderHighlightedTranscript()}
|
||||
</p>
|
||||
<div
|
||||
ref={transcriptRef}
|
||||
className="prose prose-gray dark:prose-invert max-w-none relative select-text cursor-text"
|
||||
>
|
||||
<p className="text-gray-700 dark:text-gray-300 leading-relaxed break-words select-text">
|
||||
{renderHighlightedTranscript()}
|
||||
</p>
|
||||
|
||||
{/* Selection bubble and editor moved to portal */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
transcriptMode === "expanded"
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4 absolute inset-0"
|
||||
}`}
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
transcriptMode === "expanded"
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{transcriptMode === "expanded" && transcript.segments && (
|
||||
<div
|
||||
ref={transcriptRef}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div
|
||||
ref={transcriptRef}
|
||||
className="space-y-4 relative select-text cursor-text"
|
||||
>
|
||||
{transcript.segments.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
@@ -800,11 +914,11 @@ export function AudioDetailView({ audioId }: AudioDetailViewProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-gray-700 dark:text-gray-200 leading-relaxed break-words">
|
||||
{renderSegmentWithHighlighting(segment)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-gray-700 dark:text-gray-200 leading-relaxed break-words select-text">
|
||||
{renderSegmentWithHighlighting(segment)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -819,6 +933,22 @@ export function AudioDetailView({ audioId }: AudioDetailViewProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
{notesOpen && (
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-[1fr_360px] gap-4">
|
||||
<div></div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-3 md:p-4 border border-gray-200 dark:border-gray-700 h-[520px]">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center gap-2"><StickyNote className="h-4 w-4"/> Notes</h3>
|
||||
<NotesSidebar
|
||||
notes={notes}
|
||||
onEdit={updateNote}
|
||||
onDelete={deleteNote}
|
||||
onJumpTo={(t) => { if (wavesurferRef.current) { const dur = wavesurferRef.current.getDuration(); wavesurferRef.current.seekTo(Math.min(0.999, Math.max(0, t / dur))); setCurrentTime(t); }}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -921,6 +1051,39 @@ export function AudioDetailView({ audioId }: AudioDetailViewProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Portal: add-note bubble + editor */}
|
||||
{((showSelectionMenu || showEditor) && pendingSelection) ? (
|
||||
createPortal(
|
||||
<div>
|
||||
{showSelectionMenu && (
|
||||
<div style={{ position: 'fixed', left: selectionViewportPos.x, top: selectionViewportPos.y, transform: 'translate(-50%, -100%)', zIndex: 10000 }}>
|
||||
<div className="bg-gray-900 text-white text-xs rounded-md shadow-2xl px-2 py-1 flex items-center gap-1">
|
||||
<button className="flex items-center gap-1 hover:opacity-90" onClick={openEditorForSelection}>
|
||||
<Plus className="h-3 w-3" /> Add note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showEditor && (
|
||||
<div style={{ position: 'fixed', left: selectionViewportPos.x, top: selectionViewportPos.y + 18, transform: 'translate(-50%, 0)', zIndex: 10000 }} className="w-[min(90vw,520px)]">
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-2xl p-3">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 border-l-2 border-gray-300 dark:border-gray-600 pl-2 italic mb-2 max-h-32 overflow-auto">
|
||||
{pendingSelection.quote}
|
||||
</div>
|
||||
<textarea className="w-full text-sm bg-transparent border rounded-md p-2 border-gray-300 dark:border-gray-700 text-gray-900 dark:text-gray-100" placeholder="Add a note..." value={newNoteContent} onChange={e => setNewNoteContent(e.target.value)} rows={4} />
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<button className="px-2 py-1 text-sm rounded-md bg-gray-200 dark:bg-gray-700" onClick={() => { setShowEditor(false); setPendingSelection(null); }}>{"Cancel"}</button>
|
||||
<button className="px-2 py-1 text-sm rounded-md bg-blue-600 text-white" onClick={saveNewNote}>{"Save"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
106
web/frontend/src/components/NotesSidebar.tsx
Normal file
106
web/frontend/src/components/NotesSidebar.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import type { Note } from "../types/note";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card } from "./ui/card";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Trash2, Pencil, Save, X, ExternalLink } from "lucide-react";
|
||||
|
||||
interface NotesSidebarProps {
|
||||
notes: Note[];
|
||||
onEdit: (id: string, newContent: string) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onJumpTo: (time: number) => void;
|
||||
}
|
||||
|
||||
export function NotesSidebar({ notes, onEdit, onDelete, onJumpTo }: NotesSidebarProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<string>("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const startEdit = (n: Note) => {
|
||||
setEditingId(n.id);
|
||||
setDraft(n.content);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
const saveEdit = async (id: string) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await onEdit(id, draft.trim());
|
||||
setEditingId(null);
|
||||
setDraft("");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const del = async (id: string) => {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await onDelete(id);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (s: number) => {
|
||||
const m = Math.floor(s / 60);
|
||||
const ss = Math.floor(s % 60).toString().padStart(2, "0");
|
||||
return `${m}:${ss}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto space-y-3">
|
||||
{notes.length === 0 && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">No notes yet. Select transcript text to add one.</p>
|
||||
)}
|
||||
{notes.map((n) => (
|
||||
<Card key={n.id} className="p-3 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-xs text-blue-600 dark:text-blue-300 font-mono">
|
||||
<button className="hover:underline" onClick={() => onJumpTo(n.start_time)} title="Jump to time">
|
||||
<ExternalLink className="inline h-3 w-3 mr-1" /> {formatTime(n.start_time)} - {formatTime(n.end_time)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{editingId === n.id ? (
|
||||
<>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" disabled={saving} onClick={() => saveEdit(n.id)} title="Save">
|
||||
<Save className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={cancelEdit} title="Cancel">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => startEdit(n)} title="Edit">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" disabled={deletingId === n.id} onClick={() => del(n.id)} title="Delete">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote className="text-xs text-gray-500 dark:text-gray-400 border-l-2 border-gray-300 dark:border-gray-600 pl-2 mt-2 italic select-text">
|
||||
{n.quote}
|
||||
</blockquote>
|
||||
{editingId === n.id ? (
|
||||
<div className="mt-2">
|
||||
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} rows={4} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-gray-800 dark:text-gray-100 whitespace-pre-wrap">
|
||||
{n.content}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
web/frontend/src/types/note.ts
Normal file
13
web/frontend/src/types/note.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface Note {
|
||||
id: string;
|
||||
transcription_id: string;
|
||||
start_word_index: number;
|
||||
end_word_index: number;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
quote: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user