-
Notifications
You must be signed in to change notification settings - Fork 65
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #8 from Rocketseat/transcription-edit
feat: allow user to update video transcription
- Loading branch information
Showing
10 changed files
with
323 additions
and
47 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
24 changes: 24 additions & 0 deletions
24
src/app/(app)/videos/[id]/transcription-card/transcription-skeleton.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.