mirror of
https://github.com/rishikanthc/Scriberr.git
synced 2026-07-01 08:15:46 +00:00
Connect recording detail transcript data
This commit is contained in:
@@ -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<string, string>): Promi
|
||||
return response.json() as Promise<TranscriptionsResponse>;
|
||||
}
|
||||
|
||||
export async function getTranscriptionTranscript(
|
||||
transcriptionId: string,
|
||||
headers: Record<string, string>
|
||||
): Promise<TranscriptionTranscript> {
|
||||
const response = await fetch(`/api/v1/transcriptions/${transcriptionId}/transcript`, {
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) throw new Error(await readError(response));
|
||||
return response.json() as Promise<TranscriptionTranscript>;
|
||||
}
|
||||
|
||||
export async function createTranscription(
|
||||
payload: CreateTranscriptionPayload,
|
||||
headers: Record<string, string>
|
||||
|
||||
@@ -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<DetailTab>("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() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "summary" ? <MockSummary /> : <TranscriptMock segments={mockSegments} />}
|
||||
{activeTab === "summary" ? (
|
||||
<SummaryPanel transcription={latestTranscription} />
|
||||
) : (
|
||||
<TranscriptPanel
|
||||
fileStatus={file.status}
|
||||
transcription={latestTranscription}
|
||||
transcript={transcriptQuery.data}
|
||||
isLoading={transcriptionsQuery.isLoading || transcriptQuery.isLoading}
|
||||
isError={transcriptionsQuery.isError || transcriptQuery.isError}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
<StreamingAudioPlayer
|
||||
fileId={file.id}
|
||||
@@ -193,26 +183,75 @@ export function AudioDetailView() {
|
||||
);
|
||||
}
|
||||
|
||||
function MockSummary() {
|
||||
function SummaryPanel({ transcription }: { transcription?: Transcription }) {
|
||||
if (!transcription) {
|
||||
return (
|
||||
<section className="scr-audio-summary" aria-label="Summary">
|
||||
<p>No transcription has been queued for this recording yet.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="scr-audio-summary" aria-label="Summary">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>Summary is not available yet.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 <TranscriptPlaceholder title="Loading transcript" description="Reading the latest transcription state." />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <TranscriptPlaceholder title="Transcript unavailable" description="Could not load transcript data." />;
|
||||
}
|
||||
|
||||
if (!transcription) {
|
||||
if (fileStatus === "processing") {
|
||||
return <TranscriptPlaceholder title="Audio is processing" description="Transcript actions will be available when the imported audio is ready." />;
|
||||
}
|
||||
if (fileStatus === "failed") {
|
||||
return <TranscriptPlaceholder title="Audio import failed" description="This recording cannot be transcribed until the file import succeeds." />;
|
||||
}
|
||||
return <TranscriptPlaceholder title="Not transcribed yet" description="Queue a transcription from Home to generate transcript segments for this recording." />;
|
||||
}
|
||||
|
||||
if (transcription.status === "queued" || transcription.status === "processing") {
|
||||
return (
|
||||
<TranscriptPlaceholder
|
||||
title={transcription.status === "queued" ? "Transcription queued" : "Transcription processing"}
|
||||
description={formatTranscriptionProgress(transcription)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (transcription.status === "failed") {
|
||||
return <TranscriptPlaceholder title="Transcription failed" description="Start another transcription from Home when you are ready to retry." />;
|
||||
}
|
||||
|
||||
if (transcription.status === "canceled") {
|
||||
return <TranscriptPlaceholder title="Transcription canceled" description="Start another transcription from Home to generate transcript text." />;
|
||||
}
|
||||
|
||||
const segments = normalizeTranscriptSegments(transcript);
|
||||
if (!segments.length) {
|
||||
return <TranscriptPlaceholder title="Transcript is empty" description="The completed transcription did not return any text." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="scr-transcript" aria-label="Transcript">
|
||||
{segments.map((segment) => (
|
||||
<article className="scr-transcript-segment" key={segment.id}>
|
||||
<article className="scr-transcript-segment" key={segment.id || `${segment.start}-${segment.end}`}>
|
||||
{segment.speaker ? <span className="scr-transcript-speaker">{segment.speaker}</span> : null}
|
||||
<time className="scr-transcript-time">{formatSegmentTime(segment.start)}</time>
|
||||
<p className="scr-transcript-text">{segment.text}</p>
|
||||
@@ -222,6 +261,15 @@ function TranscriptMock({ segments }: { segments: MockSegment[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptPlaceholder({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<section className="scr-transcript-placeholder" aria-label="Transcript status">
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -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<TranscriptionsResponse>(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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user