diff --git a/web/frontend/src/features/transcription/api/transcriptionsApi.ts b/web/frontend/src/features/transcription/api/transcriptionsApi.ts index dc209793..984d472d 100644 --- a/web/frontend/src/features/transcription/api/transcriptionsApi.ts +++ b/web/frontend/src/features/transcription/api/transcriptionsApi.ts @@ -12,6 +12,28 @@ export type Transcription = { updated_at: string; }; +export type TranscriptSegment = { + id?: string; + start: number; + end: number; + speaker?: string; + text: string; +}; + +export type TranscriptWord = { + start: number; + end: number; + word: string; + speaker?: string; +}; + +export type TranscriptionTranscript = { + transcription_id: string; + text: string; + segments: TranscriptSegment[]; + words: TranscriptWord[]; +}; + export type TranscriptionsResponse = { items: Transcription[]; next_cursor: string | null; @@ -31,6 +53,17 @@ export async function listTranscriptions(headers: Record): Promi return response.json() as Promise; } +export async function getTranscriptionTranscript( + transcriptionId: string, + headers: Record +): Promise { + const response = await fetch(`/api/v1/transcriptions/${transcriptionId}/transcript`, { + headers, + }); + if (!response.ok) throw new Error(await readError(response)); + return response.json() as Promise; +} + export async function createTranscription( payload: CreateTranscriptionPayload, headers: Record diff --git a/web/frontend/src/features/transcription/components/AudioDetailView.tsx b/web/frontend/src/features/transcription/components/AudioDetailView.tsx index 1725bfad..6b45b8d6 100644 --- a/web/frontend/src/features/transcription/components/AudioDetailView.tsx +++ b/web/frontend/src/features/transcription/components/AudioDetailView.tsx @@ -4,44 +4,14 @@ import { CalendarDays, Clock3, MoreHorizontal, Pause, Pencil, Play } from "lucid import { Sidebar } from "@/features/home/components/HomePage"; import { useAuth } from "@/features/auth/hooks/useAuth"; import { useFile, useUpdateFile } from "@/features/files/hooks/useFiles"; +import type { FileStatus } from "@/features/files/api/filesApi"; +import type { TranscriptSegment, TranscriptWord, Transcription, TranscriptionTranscript } from "@/features/transcription/api/transcriptionsApi"; +import { useTranscriptionDetailEvents } from "@/features/transcription/hooks/useTranscriptionDetailEvents"; +import { useTranscriptionListEvents } from "@/features/transcription/hooks/useTranscriptionListEvents"; +import { useTranscriptionTranscript, useTranscriptions } from "@/features/transcription/hooks/useTranscriptions"; type DetailTab = "summary" | "transcript"; -type MockSegment = { - id: string; - start: number; - text: string; - speaker?: string; -}; - -const mockSegments: MockSegment[] = [ - { - id: "seg-1", - start: 1, - text: "About adding event listeners", - }, - { - id: "seg-2", - start: 3, - text: "On your element. We are going to dive deeper and explain the things you need to know about adding event listeners on your elements in your app component.", - }, - { - id: "seg-3", - start: 17, - text: "So let's take a look at the repo.", - }, - { - id: "seg-4", - start: 18, - text: "Over here, I have a heading and if I want to add event listeners, this is how I would have done it. If I write vanilla JavaScript, I would have a reference to the element and I will say add event listener. Then I will pass in the event name and the event handler. There are two ways to handle this: create a function inline, or create the function ahead of time and pass the reference to this event listener method.", - }, - { - id: "seg-5", - start: 93, - text: "So that's how you say this.", - }, -]; - export function AudioDetailView() { const { audioId = "" } = useParams<{ audioId: string }>(); const [activeTab, setActiveTab] = useState("transcript"); @@ -50,10 +20,20 @@ export function AudioDetailView() { const [draftTitle, setDraftTitle] = useState(""); const fileQuery = useFile(audioId); const updateFileMutation = useUpdateFile(audioId); + const transcriptionsQuery = useTranscriptions(); const file = fileQuery.data; const title = file?.title?.trim() || "Untitled recording"; const visibleDuration = file?.duration_seconds ?? audioDuration; + const latestTranscription = useMemo(() => { + if (!file) return undefined; + return latestTranscriptionForFile(transcriptionsQuery.data?.items || [], file.id); + }, [file, transcriptionsQuery.data?.items]); + const isActiveTranscription = latestTranscription?.status === "queued" || latestTranscription?.status === "processing"; + const transcriptQuery = useTranscriptionTranscript(latestTranscription?.id, latestTranscription?.status === "completed"); + + useTranscriptionListEvents(); + useTranscriptionDetailEvents(isActiveTranscription ? latestTranscription?.id : undefined); const meta = useMemo(() => { return { @@ -179,7 +159,17 @@ export function AudioDetailView() { - {activeTab === "summary" ? : } + {activeTab === "summary" ? ( + + ) : ( + + )} +

No transcription has been queued for this recording yet.

+ + ); + } + return (
-

- This recording walks through adding event listeners, how event names and handlers are passed, and the difference - between inline callbacks and pre-defined handler functions. -

-

- The key workflow is to identify the target element, choose the relevant event, and pass a stable function reference - when the handler will be reused or benefits from being named. -

+

Summary is not available yet.

); } -function TranscriptMock({ segments }: { segments: MockSegment[] }) { +type TranscriptPanelProps = { + fileStatus: FileStatus; + transcription?: Transcription; + transcript?: TranscriptionTranscript; + isLoading: boolean; + isError: boolean; +}; + +function TranscriptPanel({ fileStatus, transcription, transcript, isLoading, isError }: TranscriptPanelProps) { + if (isLoading) { + return ; + } + + if (isError) { + return ; + } + + if (!transcription) { + if (fileStatus === "processing") { + return ; + } + if (fileStatus === "failed") { + return ; + } + return ; + } + + if (transcription.status === "queued" || transcription.status === "processing") { + return ( + + ); + } + + if (transcription.status === "failed") { + return ; + } + + if (transcription.status === "canceled") { + return ; + } + + const segments = normalizeTranscriptSegments(transcript); + if (!segments.length) { + return ; + } + return (
{segments.map((segment) => ( -
+
{segment.speaker ? {segment.speaker} : null}

{segment.text}

@@ -222,6 +261,15 @@ function TranscriptMock({ segments }: { segments: MockSegment[] }) { ); } +function TranscriptPlaceholder({ title, description }: { title: string; description: string }) { + return ( +
+

{title}

+

{description}

+
+ ); +} + type StreamingAudioPlayerProps = { fileId: string; durationSeconds: number | null; @@ -309,6 +357,82 @@ function StreamingAudioPlayer({ fileId, durationSeconds, title, onDurationChange ); } +function latestTranscriptionForFile(transcriptions: Transcription[], fileId: string) { + return transcriptions + .filter((transcription) => transcription.file_id === fileId) + .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())[0]; +} + +function normalizeTranscriptSegments(transcript?: TranscriptionTranscript): TranscriptSegment[] { + if (!transcript) return []; + if (transcript.segments.length > 1) return transcript.segments.filter((segment) => segment.text.trim()); + if (transcript.words.length > 0) return chunkWordsIntoDisplaySegments(transcript.words); + if (transcript.segments.length === 1 && transcript.segments[0].text.trim()) return transcript.segments; + const text = transcript.text.trim(); + if (!text) return []; + return [{ + id: "full-transcript", + start: 0, + end: 0, + text, + }]; +} + +function chunkWordsIntoDisplaySegments(words: TranscriptWord[]): TranscriptSegment[] { + const segments: TranscriptSegment[] = []; + let current: TranscriptWord[] = []; + + const flush = () => { + if (!current.length) return; + const first = current[0]; + const last = current[current.length - 1]; + const text = current.map((word) => word.word.trim()).filter(Boolean).join(" "); + if (text) { + segments.push({ + id: `word-segment-${segments.length}`, + start: first.start, + end: last.end, + speaker: first.speaker, + text, + }); + } + current = []; + }; + + for (const word of words) { + current.push(word); + const cleaned = word.word.trim(); + const closesSentence = /[.!?]$/.test(cleaned); + if ((closesSentence && current.length >= 10) || current.length >= 32) { + flush(); + } + } + + flush(); + return segments; +} + +function formatTranscriptionProgress(transcription: Transcription) { + const progress = normalizeProgress(transcription.progress); + const stage = transcription.progress_stage && transcription.progress_stage !== "queued" + ? transcription.progress_stage + : transcription.status; + if (progress === null) return sentenceCase(stage); + return `${sentenceCase(stage)} ยท ${progress}%`; +} + +function normalizeProgress(value: number) { + if (!Number.isFinite(value) || value <= 0) return null; + const percent = value <= 1 ? value * 100 : value; + return Math.min(100, Math.max(1, Math.round(percent))); +} + +function sentenceCase(value: string) { + const normalized = value.replace(/[_-]+/g, " ").trim(); + if (!normalized) return ""; + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + function formatCreatedDate(value: string) { return new Date(value).toLocaleString("en-US", { month: "short", diff --git a/web/frontend/src/features/transcription/hooks/useTranscriptionDetailEvents.ts b/web/frontend/src/features/transcription/hooks/useTranscriptionDetailEvents.ts new file mode 100644 index 00000000..e90089f3 --- /dev/null +++ b/web/frontend/src/features/transcription/hooks/useTranscriptionDetailEvents.ts @@ -0,0 +1,140 @@ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "@/features/auth/hooks/useAuth"; +import type { TranscriptionStatus, TranscriptionsResponse } from "@/features/transcription/api/transcriptionsApi"; +import { transcriptionTranscriptQueryKey, transcriptionsQueryKey } from "@/features/transcription/hooks/useTranscriptions"; + +type TranscriptionEvent = { + name: string; + data: { + id?: string; + status?: string; + progress?: number; + stage?: string; + }; +}; + +export function useTranscriptionDetailEvents(transcriptionId: string | undefined) { + const { token } = useAuth(); + const queryClient = useQueryClient(); + + useEffect(() => { + if (!token || !transcriptionId) return; + + const abortController = new AbortController(); + let reconnectTimer: number | undefined; + + const invalidateDetail = () => { + queryClient.invalidateQueries({ queryKey: transcriptionsQueryKey }); + queryClient.invalidateQueries({ queryKey: transcriptionTranscriptQueryKey(transcriptionId) }); + }; + + const scheduleReconnect = () => { + if (abortController.signal.aborted) return; + reconnectTimer = window.setTimeout(() => { + void connect(); + }, 1500); + }; + + const connect = async () => { + try { + const response = await fetch(`/api/v1/transcriptions/${transcriptionId}/events`, { + headers: { Authorization: `Bearer ${token}` }, + signal: abortController.signal, + }); + + if (!response.ok || !response.body) { + scheduleReconnect(); + return; + } + + invalidateDetail(); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (!abortController.signal.aborted) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const chunks = buffer.split("\n\n"); + buffer = chunks.pop() || ""; + + for (const chunk of chunks) { + const parsed = parseSSEChunk(chunk); + if (!parsed || parsed.data.id !== transcriptionId) continue; + + queryClient.setQueryData(transcriptionsQueryKey, (current) => { + if (!current) return current; + return { + ...current, + items: current.items.map((transcription) => { + if (transcription.id !== transcriptionId) return transcription; + return { + ...transcription, + status: normalizeEventStatus(parsed.data.status) || transcription.status, + progress: parsed.data.progress ?? transcription.progress, + progress_stage: parsed.data.stage || transcription.progress_stage, + updated_at: new Date().toISOString(), + }; + }), + }; + }); + + if (parsed.name === "transcription.completed" || parsed.data.status === "completed") { + queryClient.invalidateQueries({ queryKey: transcriptionTranscriptQueryKey(transcriptionId) }); + } + } + } + + scheduleReconnect(); + } catch (error) { + if ((error as Error).name === "AbortError") return; + scheduleReconnect(); + } + }; + + void connect(); + + return () => { + abortController.abort(); + if (reconnectTimer) window.clearTimeout(reconnectTimer); + }; + }, [token, transcriptionId, queryClient]); +} + +function parseSSEChunk(chunk: string): TranscriptionEvent | null { + let name = ""; + let data = ""; + + for (const line of chunk.split("\n")) { + if (line.startsWith("event:")) { + name = line.slice("event:".length).trim(); + } else if (line.startsWith("data:")) { + data += line.slice("data:".length).trim(); + } + } + + if (!name || !data) return null; + + try { + return { name, data: JSON.parse(data) }; + } catch { + return null; + } +} + +function normalizeEventStatus(status?: string): TranscriptionStatus | undefined { + switch (status) { + case "queued": + case "processing": + case "completed": + case "failed": + case "canceled": + return status; + default: + return undefined; + } +} diff --git a/web/frontend/src/features/transcription/hooks/useTranscriptions.ts b/web/frontend/src/features/transcription/hooks/useTranscriptions.ts index 47a3c2fc..6832c2b9 100644 --- a/web/frontend/src/features/transcription/hooks/useTranscriptions.ts +++ b/web/frontend/src/features/transcription/hooks/useTranscriptions.ts @@ -2,12 +2,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/features/auth/hooks/useAuth"; import { createTranscription, + getTranscriptionTranscript, listTranscriptions, type CreateTranscriptionPayload, type TranscriptionsResponse, } from "@/features/transcription/api/transcriptionsApi"; export const transcriptionsQueryKey = ["transcriptions"] as const; +export const transcriptionTranscriptQueryKey = (transcriptionId: string) => ["transcription-transcript", transcriptionId] as const; export function useTranscriptions() { const { getAuthHeaders, isAuthenticated } = useAuth(); @@ -25,6 +27,17 @@ export function useTranscriptions() { }); } +export function useTranscriptionTranscript(transcriptionId: string | undefined, enabled: boolean) { + const { getAuthHeaders, isAuthenticated } = useAuth(); + + return useQuery({ + queryKey: transcriptionId ? transcriptionTranscriptQueryKey(transcriptionId) : ["transcription-transcript", "missing"], + queryFn: () => getTranscriptionTranscript(transcriptionId || "", getAuthHeaders()), + enabled: isAuthenticated && Boolean(transcriptionId) && enabled, + staleTime: 30_000, + }); +} + export function useCreateTranscription() { const { getAuthHeaders } = useAuth(); const queryClient = useQueryClient(); diff --git a/web/frontend/src/styles/design-system.css b/web/frontend/src/styles/design-system.css index 90110edb..355c1c08 100644 --- a/web/frontend/src/styles/design-system.css +++ b/web/frontend/src/styles/design-system.css @@ -170,6 +170,27 @@ max-width: 900px; } +.scr-transcript-placeholder { + display: grid; + gap: var(--scr-space-2); + max-width: 560px; + padding-top: var(--scr-space-2); +} + +.scr-transcript-placeholder h2 { + color: var(--scr-text-primary); + font-size: 1rem; + font-weight: 600; + line-height: var(--scr-line-tight); +} + +.scr-transcript-placeholder p { + color: var(--scr-text-secondary); + font-size: 0.9375rem; + font-weight: 500; + line-height: var(--scr-line-base); +} + .scr-transcript-segment { --scr-transcript-row-line: 1.68; display: grid;