From 21617e3ebb55f960dee1659896a435c7955bf0cf Mon Sep 17 00:00:00 2001 From: Diego Fernandes Date: Fri, 14 Jul 2023 17:56:17 -0300 Subject: [PATCH] feat: allow user to update video transcription --- README.md | 4 +- src/app/(app)/layout.tsx | 4 +- src/app/(app)/videos/[id]/page.tsx | 61 ++++--- .../videos/[id]/transcription-card/index.tsx | 170 ++++++++++++++++++ .../[id]/transcription-card/segment.tsx | 26 +++ .../transcription-skeleton.tsx | 24 +++ src/app/api/transcriptions/route.ts | 36 ++++ .../api/videos/[id]/download/[media]/route.ts | 10 +- .../api/videos/[id]/transcription/route.ts | 23 ++- src/components/transcription-preview.tsx | 12 +- 10 files changed, 323 insertions(+), 47 deletions(-) create mode 100644 src/app/(app)/videos/[id]/transcription-card/index.tsx create mode 100644 src/app/(app)/videos/[id]/transcription-card/segment.tsx create mode 100644 src/app/(app)/videos/[id]/transcription-card/transcription-skeleton.tsx create mode 100644 src/app/api/transcriptions/route.ts diff --git a/README.md b/README.md index 74f178a..c998884 100644 --- a/README.md +++ b/README.md @@ -9,4 +9,6 @@ Upload videos, upload to panda and R2, embed om player # To-do - datadog / sentry -- tag logic \ No newline at end of file +- tag logic +- deadletter queue (manual retry) + - store number of retries and if its the last maybe notice somewhere and display a button for manual retry \ No newline at end of file diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 44436cf..1bdcd6e 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -2,9 +2,9 @@ import { Header } from '@/components/header' export default function AppLayout({ children }: { children: React.ReactNode }) { return ( -
+
-
{children}
+
{children}
) } diff --git a/src/app/(app)/videos/[id]/page.tsx b/src/app/(app)/videos/[id]/page.tsx index 0815bde..d39f734 100644 --- a/src/app/(app)/videos/[id]/page.tsx +++ b/src/app/(app)/videos/[id]/page.tsx @@ -7,6 +7,7 @@ import { Metadata } from 'next' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { DeleteVideoButton } from './delete-video-button' +import { TranscriptionCard } from './transcription-card' dayjs.extend(relativeTime) @@ -34,35 +35,41 @@ export default async function VideoPage({ params }: VideoPageProps) { }) return ( -
-

- {video.title} -

+ <> +
+

+ {video.title} +

-
- +
-
+ +
+ +
+ ) } diff --git a/src/app/(app)/videos/[id]/transcription-card/index.tsx b/src/app/(app)/videos/[id]/transcription-card/index.tsx new file mode 100644 index 0000000..70e172f --- /dev/null +++ b/src/app/(app)/videos/[id]/transcription-card/index.tsx @@ -0,0 +1,170 @@ +'use client' + +import { Card, CardContent, CardFooter } from '@/components/ui/card' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Segment } from './segment' +import { TranscriptionSkeleton } from './transcription-skeleton' +import { Button } from '@/components/ui/button' +import { useMutation, useQuery } from '@tanstack/react-query' +import axios from 'axios' +import { Controller, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { Fragment, useRef, useState } from 'react' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { Loader2 } from 'lucide-react' + +interface TranscriptionCardProps { + videoId: string +} + +const transcriptionSegmentsFormSchema = z.object({ + segments: z.array( + z.object({ + id: z.string(), + text: z.string(), + }), + ), +}) + +type TranscriptionSegmentsFormSchema = z.infer< + typeof transcriptionSegmentsFormSchema +> + +export function TranscriptionCard({ videoId }: TranscriptionCardProps) { + const [shouldFollowUserFocus, setShouldFollowUserFocus] = useState(true) + + const { data: transcription } = useQuery( + ['transcription', videoId], + async () => { + const response = await axios.get(`/api/videos/${videoId}/transcription`) + + return response.data.transcription + }, + { + refetchInterval(data) { + const isTranscriptionAlreadyLoaded = !!data + + if (isTranscriptionAlreadyLoaded) { + return false + } + + return 15 * 1000 // 15 seconds + }, + }, + ) + + const { mutateAsync: saveTranscriptions } = useMutation( + async (data: TranscriptionSegmentsFormSchema) => { + await axios.put(`/api/transcriptions`, data) + }, + ) + + const videoRef = useRef(null) + + const { + register, + control, + handleSubmit, + formState: { isSubmitting }, + } = useForm({ + resolver: zodResolver(transcriptionSegmentsFormSchema), + }) + + function handleSegmentFocused({ start }: { start: number }) { + if (videoRef.current && shouldFollowUserFocus) { + videoRef.current.currentTime = start + videoRef.current.play() + } + } + + async function handleSaveTranscriptionSegments( + data: TranscriptionSegmentsFormSchema, + ) { + await saveTranscriptions(data) + } + + return ( +
+ + +
+ ) +} diff --git a/src/app/(app)/videos/[id]/transcription-card/segment.tsx b/src/app/(app)/videos/[id]/transcription-card/segment.tsx new file mode 100644 index 0000000..4622a7f --- /dev/null +++ b/src/app/(app)/videos/[id]/transcription-card/segment.tsx @@ -0,0 +1,26 @@ +export interface SegmentProps { + value: string + onValueChange: (value: string) => void + onFocus: () => void + onBlur: () => void +} + +export function Segment({ + value, + onValueChange, + onFocus, + onBlur, +}: SegmentProps) { + return ( + onValueChange(e.currentTarget.textContent ?? '')} + className="rounded p-1 outline-none hover:bg-primary/10 focus:bg-violet-500 focus:text-white" + > + {value} + + ) +} diff --git a/src/app/(app)/videos/[id]/transcription-card/transcription-skeleton.tsx b/src/app/(app)/videos/[id]/transcription-card/transcription-skeleton.tsx new file mode 100644 index 0000000..babdaa4 --- /dev/null +++ b/src/app/(app)/videos/[id]/transcription-card/transcription-skeleton.tsx @@ -0,0 +1,24 @@ +import { CardContent } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { Loader2 } from 'lucide-react' + +export function TranscriptionSkeleton() { + return ( + <> + + {Array.from({ length: 20 }).map((_, row) => { + return + })} + +
+ +
+ Transcription is being generated + + The page will automatically refresh... + +
+
+ + ) +} diff --git a/src/app/api/transcriptions/route.ts b/src/app/api/transcriptions/route.ts new file mode 100644 index 0000000..eacb066 --- /dev/null +++ b/src/app/api/transcriptions/route.ts @@ -0,0 +1,36 @@ +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const updateTranscriptionSegmentsBodySchema = z.object({ + segments: z.array( + z.object({ + id: z.string(), + text: z.string(), + }), + ), +}) + +export async function PUT(request: Request) { + const { segments } = updateTranscriptionSegmentsBodySchema.parse( + await request.json(), + ) + + try { + await prisma.$transaction( + segments.map((segment) => { + return prisma.transcriptionSegment.update({ + where: { + id: segment.id, + }, + data: { + text: segment.text, + }, + }) + }), + ) + + return new Response() + } catch (err) { + console.log(err) + } +} diff --git a/src/app/api/videos/[id]/download/[media]/route.ts b/src/app/api/videos/[id]/download/[media]/route.ts index cc6b0d1..40bad4a 100644 --- a/src/app/api/videos/[id]/download/[media]/route.ts +++ b/src/app/api/videos/[id]/download/[media]/route.ts @@ -32,10 +32,16 @@ export async function GET( Bucket: env.CLOUDFLARE_BUCKET_NAME, Key: media === 'video' ? video.storageKey : video.audioStorageKey, }), - { expiresIn: 600 }, + { expiresIn: 60 * 60 /* 1 hour */ }, ) - return NextResponse.redirect(downloadSignedUrl) + return NextResponse.redirect(downloadSignedUrl, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }) } catch (err) { console.log(err) } diff --git a/src/app/api/videos/[id]/transcription/route.ts b/src/app/api/videos/[id]/transcription/route.ts index 2cbb9d3..9f32e1a 100644 --- a/src/app/api/videos/[id]/transcription/route.ts +++ b/src/app/api/videos/[id]/transcription/route.ts @@ -1,27 +1,24 @@ import { prisma } from '@/lib/prisma' import { NextResponse } from 'next/server' -interface GetTranscriptionParams { +interface TranscriptionParams { params: { id: string } } -export async function GET(_: Request, { params }: GetTranscriptionParams) { +export async function GET(_: Request, { params }: TranscriptionParams) { const videoId = params.id try { - const [transcription] = await prisma.$queryRaw< - [{ text: string; id: string }] - >/* sql */ ` - SELECT - "public"."TranscriptionSegment"."transcriptionId" as "id", - STRING_AGG("public"."TranscriptionSegment"."text", '') as "text" - FROM "public"."TranscriptionSegment" - JOIN "public"."Transcription" ON "public"."Transcription"."id" = "public"."TranscriptionSegment"."transcriptionId" - WHERE "public"."Transcription"."videoId" = ${videoId} - GROUP BY "public"."TranscriptionSegment"."transcriptionId" - ` + const transcription = await prisma.transcription.findUniqueOrThrow({ + where: { + videoId, + }, + include: { + segments: true, + }, + }) if (!transcription) { return NextResponse.json( diff --git a/src/components/transcription-preview.tsx b/src/components/transcription-preview.tsx index 957e95f..c0d1851 100644 --- a/src/components/transcription-preview.tsx +++ b/src/components/transcription-preview.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useMemo, useState } from 'react' import axios from 'axios' import { Link1Icon } from '@radix-ui/react-icons' import { useQuery } from '@tanstack/react-query' @@ -37,6 +37,14 @@ export function TranscriptionPreview({ videoId }: TranscriptionPreviewProps) { }, ) + const transcriptionText = useMemo(() => { + if (!transcription) { + return '' + } + + return transcription.segments.map((segment: any) => segment.text).join('') + }, [transcription]) + return ( @@ -60,7 +68,7 @@ export function TranscriptionPreview({ videoId }: TranscriptionPreviewProps) {