fix: styling inconsistencies and move actins menu to context menu

This commit is contained in:
rishikanthc
2025-12-11 09:55:33 -08:00
parent 11ea544fb1
commit fb7d26862d
2 changed files with 233 additions and 174 deletions

View File

@@ -1,6 +1,6 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { MoreVertical, Edit2, Activity, FileText, Bot, Check, Loader2 } from "lucide-react";
import { MoreVertical, Edit2, Activity, FileText, Bot, Check, Loader2, List, AlignLeft, ArrowDownCircle, StickyNote, Users, MessageCircle, FileImage, FileJson } from "lucide-react";
import { Header } from "@/components/Header";
import { Button } from "@/components/ui/button";
@@ -12,6 +12,8 @@ import { cn } from "@/lib/utils";
// Custom Hooks
import { useAudioDetail, useUpdateTitle, useTranscript } from "@/features/transcription/hooks/useAudioDetail";
import { useSpeakerMappings } from "@/features/transcription/hooks/useTranscriptionSpeakers";
import { useTranscriptDownload } from "@/features/transcription/hooks/useTranscriptDownload";
// Sub-components
import { TranscriptSection } from "./audio-detail/TranscriptSection";
@@ -38,6 +40,14 @@ export const AudioDetailView = function AudioDetailView({ audioId: propAudioId }
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [newTitle, setNewTitle] = useState("");
// Lifted Transcript State
const [transcriptMode, setTranscriptMode] = useState<"compact" | "expanded">("compact");
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [notesOpen, setNotesOpen] = useState(false);
const [speakerRenameOpen, setSpeakerRenameOpen] = useState(false);
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [downloadFormat, setDownloadFormat] = useState<'txt' | 'json'>('txt');
// Dialog States
const [executionDialogOpen, setExecutionDialogOpen] = useState(false);
const [logsDialogOpen, setLogsDialogOpen] = useState(false);
@@ -46,6 +56,25 @@ export const AudioDetailView = function AudioDetailView({ audioId: propAudioId }
// Data Fetching
const { data: audioFile, isLoading, error } = useAudioDetail(audioId || "");
const { mutate: updateTitle } = useUpdateTitle(audioId || "");
// Fetch transcript & speakers here to support menu actions
const { data: transcript } = useTranscript(audioId || "", true);
const { data: speakerMappings = {} } = useSpeakerMappings(audioId || "", true);
// Download Logic
const { downloadSRT } = useTranscriptDownload();
// Helpers
const getDetectedSpeakers = useCallback(() => {
if (!transcript?.segments) return [];
const speakers = new Set<string>();
transcript.segments.forEach((segment: any) => {
if (segment.speaker) speakers.add(segment.speaker);
});
return Array.from(speakers).sort();
}, [transcript]);
const hasSpeakers = audioFile?.diarization || audioFile?.parameters?.diarize || audioFile?.is_multi_track || false;
const detectedSpeakers = getDetectedSpeakers();
// Effects
useEffect(() => {
@@ -111,16 +140,17 @@ export const AudioDetailView = function AudioDetailView({ audioId: propAudioId }
{/*
1. Header Redesign:
- Using shared Header component for consistency
- Padding matches Dashboard (px-4 sm:px-8)
*/}
<div className="max-w-6xl mx-auto w-full px-6 py-6">
<div className="max-w-6xl mx-auto w-full px-4 sm:px-8 py-6">
<Header
onFileSelect={() => { }} // No file upload in detail view
/>
</div>
{/* Main Content */}
<main className="flex-1 overflow-y-auto overflow-x-hidden p-6 pb-32">
<div className="max-w-6xl mx-auto space-y-8">
<main className="flex-1 overflow-y-auto overflow-x-hidden px-4 sm:px-8 pb-32 max-w-6xl mx-auto">
<div className="mx-auto space-y-8">
{/*
2. Metadata Section (New):
@@ -189,18 +219,64 @@ export const AudioDetailView = function AudioDetailView({ audioId: propAudioId }
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 glass-card rounded-[var(--radius-card)] shadow-[var(--shadow-float)] border-[var(--border-subtle)] p-1.5">
<DropdownMenuLabel className="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wider px-2 py-1.5">
Recording Options
View Options
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setExecutionDialogOpen(true)} className="rounded-[8px] cursor-pointer text-[var(--text-secondary)] focus:text-[var(--text-primary)] focus:bg-[var(--bg-main)]/80">
<Activity className="mr-2 h-4 w-4 opacity-70" /> Execution Info
<DropdownMenuItem onClick={() => setTranscriptMode(transcriptMode === 'compact' ? 'expanded' : 'compact')} className="rounded-[8px] cursor-pointer">
{transcriptMode === 'compact' ? <List className="mr-2 h-4 w-4 opacity-70" /> : <AlignLeft className="mr-2 h-4 w-4 opacity-70" />}
{transcriptMode === 'compact' ? 'Timeline View' : 'Compact View'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLogsDialogOpen(true)} className="rounded-[8px] cursor-pointer text-[var(--text-secondary)] focus:text-[var(--text-primary)] focus:bg-[var(--bg-main)]/80">
<FileText className="mr-2 h-4 w-4 opacity-70" /> View Logs
<DropdownMenuItem onClick={() => setAutoScrollEnabled(!autoScrollEnabled)} className="rounded-[8px] cursor-pointer">
<ArrowDownCircle className={cn("mr-2 h-4 w-4 opacity-70", autoScrollEnabled && "text-[var(--brand-solid)]")} />
Auto Scroll {autoScrollEnabled ? 'On' : 'Off'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setNotesOpen(!notesOpen)} className="rounded-[8px] cursor-pointer">
<StickyNote className={cn("mr-2 h-4 w-4 opacity-70", notesOpen && "text-[var(--brand-solid)]")} />
Notes
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[var(--border-subtle)] my-1" />
<DropdownMenuLabel className="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wider px-2 py-1.5">
Actions
</DropdownMenuLabel>
{hasSpeakers && detectedSpeakers.length > 0 && (
<DropdownMenuItem onClick={() => setSpeakerRenameOpen(true)} className="rounded-[8px] cursor-pointer">
<Users className="mr-2 h-4 w-4 opacity-70" /> Rename Speakers
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => navigate(`/audio/${audioId}/chat`)} className="rounded-[8px] cursor-pointer">
<MessageCircle className="mr-2 h-4 w-4 opacity-70" /> Chat with Audio
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSummaryDialogOpen(true)} className="rounded-[8px] cursor-pointer text-[var(--brand-solid)] focus:text-[var(--brand-solid)] focus:bg-[var(--brand-light)]">
<Bot className="mr-2 h-4 w-4" /> AI Summary
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[var(--border-subtle)] my-1" />
<DropdownMenuLabel className="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wider px-2 py-1.5">
Downloads
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => transcript && downloadSRT(transcript, audioFile?.title || 'transcript', speakerMappings)} className="rounded-[8px] cursor-pointer">
<FileImage className="mr-2 h-4 w-4 opacity-70" /> Download SRT
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDownloadFormat('txt'); setDownloadDialogOpen(true); }} className="rounded-[8px] cursor-pointer">
<AlignLeft className="mr-2 h-4 w-4 opacity-70" /> Download Text
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDownloadFormat('json'); setDownloadDialogOpen(true); }} className="rounded-[8px] cursor-pointer">
<FileJson className="mr-2 h-4 w-4 opacity-70" /> Download JSON
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[var(--border-subtle)] my-1" />
<DropdownMenuLabel className="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wider px-2 py-1.5">
System
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setExecutionDialogOpen(true)} className="rounded-[8px] cursor-pointer">
<Activity className="mr-2 h-4 w-4 opacity-70" /> Execution Info
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLogsDialogOpen(true)} className="rounded-[8px] cursor-pointer">
<FileText className="mr-2 h-4 w-4 opacity-70" /> View Logs
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -236,10 +312,19 @@ export const AudioDetailView = function AudioDetailView({ audioId: propAudioId }
audioId={audioId}
currentTime={currentTime}
onSeek={handleSeek}
onOpenExecutionInfo={() => setExecutionDialogOpen(true)}
onOpenLogs={() => setLogsDialogOpen(true)}
onOpenSummarize={() => setSummaryDialogOpen(true)}
llmReady={true}
// Pass down lifted state & setters
transcript={transcript}
speakerMappings={speakerMappings}
transcriptMode={transcriptMode}
autoScrollEnabled={autoScrollEnabled}
notesOpen={notesOpen}
setNotesOpen={setNotesOpen}
speakerRenameOpen={speakerRenameOpen}
setSpeakerRenameOpen={setSpeakerRenameOpen}
downloadDialogOpen={downloadDialogOpen}
setDownloadDialogOpen={setDownloadDialogOpen}
downloadFormat={downloadFormat}
/>
</div>
</main>
@@ -266,9 +351,9 @@ export const AudioDetailView = function AudioDetailView({ audioId: propAudioId }
};
// Wrapper to handle transcript word index calculation without polluting main view
function TranscriptSectionWrapper({ audioId, currentTime, ...props }: any) {
const { data: transcript } = useTranscript(audioId, true);
// Wrapper to handle word index calc
function TranscriptSectionWrapper({ audioId, currentTime, transcript, ...props }: any) {
// If transcript not passed (loading?), handle it
let currentWordIndex = null;
if (transcript?.word_segments) {
// Simple linear find for now.
@@ -281,6 +366,7 @@ function TranscriptSectionWrapper({ audioId, currentTime, ...props }: any) {
audioId={audioId}
currentTime={currentTime}
currentWordIndex={currentWordIndex}
transcript={transcript}
{...props}
/>
);

View File

@@ -1,13 +1,8 @@
import { useState, useRef, useEffect } from "react";
import { useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { TranscriptView } from "@/components/transcript/TranscriptView";
import { TranscriptToolbar } from "@/components/transcript/TranscriptToolbar";
import { useNotes, useCreateNote, useUpdateNote, useDeleteNote } from "@/features/transcription/hooks/useTranscriptionNotes";
import { useSpeakerMappings } from "@/features/transcription/hooks/useTranscriptionSpeakers";
import { useTranscript, useAudioDetail } from "@/features/transcription/hooks/useAudioDetail";
import { useTranscriptSelection } from "@/features/transcription/hooks/useTranscriptSelection";
import { useTranscriptDownload } from "@/features/transcription/hooks/useTranscriptDownload";
import { useNavigate } from "react-router-dom";
import { DownloadDialog } from "./DownloadDialog";
import SpeakerRenameDialog from "./SpeakerRenameDialog";
import { NotesSidebar } from "./NotesSidebar";
@@ -15,50 +10,50 @@ import { TranscriptSelectionMenu } from "./TranscriptSelectionMenu";
import { NoteEditorDialog } from "./NoteEditorDialog";
import { useIsMobile } from "@/hooks/use-mobile";
import { X, StickyNote } from "lucide-react";
import type { Transcript } from "@/features/transcription/hooks/useAudioDetail";
interface TranscriptSectionProps {
audioId: string;
currentWordIndex: number | null;
onSeek: (time: number) => void;
onOpenExecutionInfo: () => void;
onOpenLogs: () => void;
onOpenSummarize: () => void;
llmReady: boolean | null;
// Lifted State Props
transcript: Transcript | undefined;
speakerMappings: Record<string, string>;
transcriptMode: 'compact' | 'expanded';
autoScrollEnabled: boolean;
notesOpen: boolean;
setNotesOpen: (open: boolean) => void;
speakerRenameOpen: boolean;
setSpeakerRenameOpen: (open: boolean) => void;
downloadDialogOpen: boolean;
setDownloadDialogOpen: (open: boolean) => void;
downloadFormat: 'txt' | 'json';
}
export function TranscriptSection({
audioId,
currentWordIndex,
onSeek,
onOpenExecutionInfo,
onOpenLogs,
onOpenSummarize,
llmReady
transcript,
speakerMappings,
transcriptMode,
autoScrollEnabled,
notesOpen,
setNotesOpen,
speakerRenameOpen,
setSpeakerRenameOpen,
downloadDialogOpen,
setDownloadDialogOpen,
downloadFormat
}: TranscriptSectionProps) {
const navigate = useNavigate();
const isMobile = useIsMobile();
// Data hooks
const { data: transcript } = useTranscript(audioId, true);
const { data: audioFile } = useAudioDetail(audioId);
// const { data: audioFile } = useAudioDetail(audioId); // Unused
const { data: notes = [] } = useNotes(audioId);
const { mutate: createNote } = useCreateNote(audioId);
const { mutateAsync: updateNote } = useUpdateNote(audioId);
const { mutateAsync: deleteNote } = useDeleteNote(audioId);
const { data: speakerMappings = {} } = useSpeakerMappings(audioId, true);
// Download Logic
const { downloadSRT } = useTranscriptDownload();
// Local state
const [transcriptMode, setTranscriptMode] = useState<"compact" | "expanded">("compact");
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [notesOpen, setNotesOpen] = useState(false);
const [speakerRenameOpen, setSpeakerRenameOpen] = useState(false);
// Download Dialog State
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [downloadFormat, setDownloadFormat] = useState<'txt' | 'json'>('txt');
// Refs
const transcriptRef = useRef<HTMLDivElement>(null);
@@ -117,18 +112,16 @@ export function TranscriptSection({
// Helpers
const getDetectedSpeakers = () => {
// Safe check for segments array
if (!transcript?.segments) return [];
const speakers = new Set<string>();
transcript.segments.forEach(segment => {
// Using any for segment here if strict typing is an issue, or typed via Transcript
transcript.segments.forEach((segment: any) => {
if (segment.speaker) speakers.add(segment.speaker);
});
return Array.from(speakers).sort();
};
const hasSpeakers = () => {
return audioFile?.diarization || audioFile?.parameters?.diarize || audioFile?.is_multi_track || false;
};
const handleSaveNote = (content: string) => {
if (pendingSelection) {
createNote({
@@ -154,140 +147,120 @@ export function TranscriptSection({
if (!transcript) return null;
return (
<div className="bg-[var(--bg-main)] rounded-[var(--radius-card)] border border-[var(--border-subtle)] p-4 md:p-6 transition-all duration-300">
{/* Sticky Toolbar */}
<div className="mb-6 sticky top-4 z-10 flex justify-center pointer-events-none">
<div className="pointer-events-auto bg-[var(--bg-card)]/80 backdrop-blur-xl border border-[var(--border-subtle)] shadow-lg rounded-[var(--radius-card)] p-1">
<TranscriptToolbar
transcriptMode={transcriptMode}
setTranscriptMode={setTranscriptMode}
autoScrollEnabled={autoScrollEnabled}
setAutoScrollEnabled={setAutoScrollEnabled}
notesOpen={notesOpen}
setNotesOpen={setNotesOpen}
notes={notes}
onOpenExecutionInfo={onOpenExecutionInfo}
onOpenLogs={onOpenLogs}
hasSpeakers={hasSpeakers()}
detectedSpeakersCount={getDetectedSpeakers().length}
onOpenSpeakerRename={() => setSpeakerRenameOpen(true)}
onOpenSummarize={onOpenSummarize}
llmReady={llmReady}
onDownloadSRT={() => downloadSRT(transcript, audioFile?.title || 'transcript', speakerMappings)}
onDownloadTXT={() => { setDownloadFormat('txt'); setDownloadDialogOpen(true); }}
onDownloadJSON={() => { setDownloadFormat('json'); setDownloadDialogOpen(true); }}
onOpenChat={() => navigate(`/audio/${audioId}/chat`)}
/>
</div>
</div>
<div className="bg-[var(--bg-card)] rounded-[var(--radius-card)] border border-[var(--border-subtle)] shadow-[var(--shadow-card)] p-1 overflow-hidden transition-shadow hover:shadow-[var(--shadow-float)]">
<div className="bg-[var(--bg-main)]/50 rounded-[calc(var(--radius-card)-4px)] p-4 md:p-6 min-h-[500px]">
{/*
TOOLBAR REMOVED -> Moved to Context Menu
*/}
{/* Transcript Content - Systematic Typography */}
<div className="relative overflow-hidden font-sans">
<div className="w-full text-[var(--text-secondary)] leading-relaxed">
<div ref={transcriptRef} className="relative">
<TranscriptView
transcript={transcript}
mode={transcriptMode}
currentWordIndex={currentWordIndex}
notes={notes}
highlightedWordRef={highlightedWordRef}
speakerMappings={speakerMappings}
autoScrollEnabled={autoScrollEnabled}
/>
{/* Transcript Content - Systematic Typography */}
<div className="relative overflow-hidden font-sans">
<div className="w-full text-[var(--text-secondary)] leading-relaxed">
<div ref={transcriptRef} className="relative">
<TranscriptView
transcript={transcript}
mode={transcriptMode}
currentWordIndex={currentWordIndex}
notes={notes}
highlightedWordRef={highlightedWordRef}
speakerMappings={speakerMappings}
autoScrollEnabled={autoScrollEnabled}
/>
</div>
</div>
</div>
</div>
{/* Download Dialog */}
<DownloadDialog
audioId={audioId}
isOpen={downloadDialogOpen}
onClose={setDownloadDialogOpen}
initialFormat={downloadFormat}
/>
{/* Download Dialog */}
<DownloadDialog
audioId={audioId}
isOpen={downloadDialogOpen}
onClose={setDownloadDialogOpen}
initialFormat={downloadFormat}
/>
{/* Speaker Rename Dialog */}
<SpeakerRenameDialog
open={speakerRenameOpen}
onOpenChange={setSpeakerRenameOpen}
transcriptionId={audioId}
initialSpeakers={getDetectedSpeakers()}
onSpeakerMappingsUpdate={() => { }}
/>
{/* Speaker Rename Dialog */}
<SpeakerRenameDialog
open={speakerRenameOpen}
onOpenChange={setSpeakerRenameOpen}
transcriptionId={audioId}
initialSpeakers={getDetectedSpeakers()}
onSpeakerMappingsUpdate={() => { }}
/>
{/* Portals */}
{createPortal(
<>
{/* Selection Menu Bubble (Glass) */}
<div className="z-[9999]">
<TranscriptSelectionMenu
isOpen={showSelectionMenu}
isMobile={isMobile}
{/* Portals */}
{createPortal(
<>
{/* Selection Menu Bubble (Glass) */}
<div className="z-[9999]">
<TranscriptSelectionMenu
isOpen={showSelectionMenu}
isMobile={isMobile}
position={selectionViewportPos}
onAddNote={openEditor}
onListenFromHere={handleListenFromHere}
/>
</div>
{/* Note Editor Dialog */}
<NoteEditorDialog
isOpen={showEditor}
quote={pendingSelection?.quote || ""}
position={selectionViewportPos}
onAddNote={openEditor}
onListenFromHere={handleListenFromHere}
onSave={handleSaveNote}
onCancel={closeEditor}
/>
</div>
{/* Note Editor Dialog */}
<NoteEditorDialog
isOpen={showEditor}
quote={pendingSelection?.quote || ""}
position={selectionViewportPos}
onSave={handleSaveNote}
onCancel={closeEditor}
/>
{/* Backdrop for menu/editor */}
{(showSelectionMenu || showEditor) && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 9995, background: 'transparent' }}
onMouseDown={() => {
if (showSelectionMenu && !showEditor) {
closeEditor();
}
}}
/>
)}
{/* Backdrop for menu/editor */}
{(showSelectionMenu || showEditor) && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 9995, background: 'transparent' }}
onMouseDown={() => {
if (showSelectionMenu && !showEditor) {
closeEditor();
}
}}
/>
)}
{/* Notes Sidebar - Premium Drawer */}
{notesOpen && (
<div className="fixed inset-y-0 right-0 w-[90vw] max-w-[400px] bg-[var(--bg-card)] border-l border-[var(--border-subtle)] shadow-[var(--shadow-float)] z-[9990] transition-transform duration-300 transform-gpu">
<div className="h-full flex flex-col">
<div className="px-6 py-5 border-b border-[var(--border-subtle)] flex items-center justify-between">
<h3 className="font-bold text-[var(--text-primary)] flex items-center gap-2 text-lg">
<StickyNote className="h-5 w-5 text-[var(--brand-solid)]" />
Notes
<span className="ml-1 text-xs font-semibold rounded-full px-2 py-0.5 bg-[var(--bg-main)] text-[var(--text-secondary)] border border-[var(--border-subtle)]">
{notes.length}
</span>
</h3>
<button
type="button"
onClick={() => setNotesOpen(false)}
className="h-8 w-8 inline-flex items-center justify-center rounded-[var(--radius-btn)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-main)] transition-colors"
aria-label="Close notes"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
<NotesSidebar
notes={notes}
onEdit={(id, content) => updateNote({ id, content })}
onDelete={(id) => deleteNote(id)}
onJumpTo={(t) => {
onSeek(t);
if (isMobile) setNotesOpen(false);
}}
/>
{/* Notes Sidebar - Premium Drawer */}
{notesOpen && (
<div className="fixed inset-y-0 right-0 w-[90vw] max-w-[400px] bg-[var(--bg-card)] border-l border-[var(--border-subtle)] shadow-[var(--shadow-float)] z-[9990] transition-transform duration-300 transform-gpu">
<div className="h-full flex flex-col">
<div className="px-6 py-5 border-b border-[var(--border-subtle)] flex items-center justify-between">
<h3 className="font-bold text-[var(--text-primary)] flex items-center gap-2 text-lg">
<StickyNote className="h-5 w-5 text-[var(--brand-solid)]" />
Notes
<span className="ml-1 text-xs font-semibold rounded-full px-2 py-0.5 bg-[var(--bg-main)] text-[var(--text-secondary)] border border-[var(--border-subtle)]">
{notes.length}
</span>
</h3>
<button
type="button"
onClick={() => setNotesOpen(false)}
className="h-8 w-8 inline-flex items-center justify-center rounded-[var(--radius-btn)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-main)] transition-colors"
aria-label="Close notes"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
<NotesSidebar
notes={notes}
onEdit={(id, content) => updateNote({ id, content })}
onDelete={(id) => deleteNote(id)}
onJumpTo={(t) => {
onSeek(t);
if (isMobile) setNotesOpen(false);
}}
/>
</div>
</div>
</div>
</div>
)}
</>,
document.body
)}
)}
</>,
document.body
)}
</div>
</div>
);
}