Skip to content

Commit

Permalink
Merge pull request #8 from Rocketseat/transcription-edit
Browse files Browse the repository at this point in the history
feat: allow user to update video transcription
  • Loading branch information
diego3g authored Jul 14, 2023
2 parents c1be8df + 21617e3 commit fb03d6c
Show file tree
Hide file tree
Showing 10 changed files with 323 additions and 47 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ Upload videos, upload to panda and R2, embed om player
# To-do

- datadog / sentry
- tag logic
- 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
4 changes: 2 additions & 2 deletions src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { Header } from '@/components/header'

export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-col">
<div className="flex min-h-screen flex-col">
<Header />
<div className="flex-1 space-y-4 p-8 pt-6">{children}</div>
<div className="flex flex-1 flex-col gap-4 p-8 pt-6">{children}</div>
</div>
)
}
61 changes: 34 additions & 27 deletions src/app/(app)/videos/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -34,35 +35,41 @@ export default async function VideoPage({ params }: VideoPageProps) {
})

return (
<div className="flex items-center justify-between gap-4">
<h2 className="truncate text-3xl font-bold tracking-tight">
{video.title}
</h2>
<>
<div className="flex items-center justify-between gap-4">
<h2 className="truncate text-3xl font-bold tracking-tight">
{video.title}
</h2>

<div className="flex items-center gap-2">
<DeleteVideoButton videoId={videoId} />
<div className="flex items-center gap-2">
<DeleteVideoButton videoId={videoId} />

<Button variant="secondary" asChild>
<a
href={`/api/videos/${video.id}/download/video`}
target="_blank"
rel="noreferrer"
>
<VideoIcon className="mr-2 h-4 w-4" />
<span>Download MP4</span>
</a>
</Button>
<Button variant="secondary" asChild>
<a
href={`/api/videos/${video.id}/download/audio`}
target="_blank"
rel="noreferrer"
>
<Music2 className="mr-2 h-4 w-4" />
<span>Download MP3</span>
</a>
</Button>
<Button variant="secondary" asChild>
<a
href={`/api/videos/${video.id}/download/video`}
target="_blank"
rel="noreferrer"
>
<VideoIcon className="mr-2 h-4 w-4" />
<span>Download MP4</span>
</a>
</Button>
<Button variant="secondary" asChild>
<a
href={`/api/videos/${video.id}/download/audio`}
target="_blank"
rel="noreferrer"
>
<Music2 className="mr-2 h-4 w-4" />
<span>Download MP3</span>
</a>
</Button>
</div>
</div>
</div>

<div className="grid flex-1 grid-cols-3 gap-4">
<TranscriptionCard videoId={video.id} />
</div>
</>
)
}
170 changes: 170 additions & 0 deletions src/app/(app)/videos/[id]/transcription-card/index.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLVideoElement>(null)

const {
register,
control,
handleSubmit,
formState: { isSubmitting },
} = useForm<TranscriptionSegmentsFormSchema>({
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 (
<div className="relative">
<Card className="absolute bottom-0 left-0 right-0 top-0 grid grid-rows-[min-content_1fr_min-content]">
<video
ref={videoRef}
crossOrigin="anonymous"
controls
preload="metadata"
src={`/api/videos/${videoId}/download/video`}
className="aspect-video w-full"
/>

<ScrollArea className="h-full w-full">
{transcription ? (
<CardContent className="p-4 leading-relaxed">
{transcription.segments.map((segment: any, index: number) => {
return (
<Fragment key={segment.id}>
{index > 0 && (
<span className="mx-1 text-sm text-primary/40">/</span>
)}
<input
type="hidden"
value={segment.id}
{...register(`segments.${index}.id`)}
/>
<Controller
name={`segments.${index}.text`}
control={control}
defaultValue={segment.text}
render={({ field }) => {
return (
<Segment
/**
* We don't use `field.value` here because it would
* cause new rerenders on every input.
*/
value={segment.text}
onValueChange={field.onChange}
onBlur={field.onBlur}
onFocus={() =>
handleSegmentFocused({
start: segment.start,
})
}
/>
)
}}
/>
</Fragment>
)
})}
</CardContent>
) : (
<TranscriptionSkeleton />
)}
</ScrollArea>
<CardFooter className="flex items-center justify-between border-t p-4">
<div className="flex items-center space-x-2">
<Switch
checked={shouldFollowUserFocus}
onCheckedChange={setShouldFollowUserFocus}
/>
<Label htmlFor="airplane-mode">Sync video & clicks</Label>
</div>

<Button
onClick={handleSubmit(handleSaveTranscriptionSegments)}
variant="secondary"
className="w-20"
disabled={!transcription || isSubmitting}
>
{isSubmitting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<span>Save</span>
)}
</Button>
</CardFooter>
</Card>
</div>
)
}
26 changes: 26 additions & 0 deletions src/app/(app)/videos/[id]/transcription-card/segment.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span
contentEditable
suppressContentEditableWarning={true}
onFocus={onFocus}
onBlur={onBlur}
onInput={(e) => onValueChange(e.currentTarget.textContent ?? '')}
className="rounded p-1 outline-none hover:bg-primary/10 focus:bg-violet-500 focus:text-white"
>
{value}
</span>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<>
<CardContent className="select-none space-y-2 p-4 leading-relaxed opacity-60 blur-sm">
{Array.from({ length: 20 }).map((_, row) => {
return <Skeleton key={row} className="h-4 w-full" />
})}
</CardContent>
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-3 text-center text-sm text-card-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
<div className="flex flex-col gap-1">
Transcription is being generated
<span className="text-xs text-muted-foreground">
The page will automatically refresh...
</span>
</div>
</div>
</>
)
}
36 changes: 36 additions & 0 deletions src/app/api/transcriptions/route.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 8 additions & 2 deletions src/app/api/videos/[id]/download/[media]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading

0 comments on commit fb03d6c

Please sign in to comment.