ability to highlight and annotate transcript with notes

This commit is contained in:
rishikanthc
2025-08-25 18:39:02 -07:00
parent a73cd6badb
commit 489e6af50d
11 changed files with 736 additions and 224 deletions

View 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(&notes).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)
}

View File

@@ -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

View File

@@ -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
View 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"`
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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>
);

View 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>
);
}

View 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;
}