-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement end-to-end video upload flow on frontend #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
a134fa5
feat(frontend): create useVideoUpload hook and api util functions
keanu-a 159ea36
feat(frontend): create upload video form and page
keanu-a 5df26e8
fix(backend): add PATCH to cors allowed methods
keanu-a 9803e1c
fix(frontend): add polling timeout and overwrite existing interval logic
keanu-a 071be35
fix: add null to playbackId and add role alert to form error
keanu-a c2b500a
chore(frontend): set TODO for apiFetch error handling
keanu-a File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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 hidden or 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,9 @@ | ||
| import { ReactNode } from 'react'; | ||
|
|
||
| export default function UploadLayout({ children }: { children: ReactNode }) { | ||
| return ( | ||
| <div className="flex min-h-svh items-center justify-center p-6 md:p-10"> | ||
| <div className="w-full max-w-sm">{children}</div> | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or 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,19 @@ | ||
| import { redirect } from 'next/navigation'; | ||
|
|
||
| import { createClient } from '@/lib/supabase/server'; | ||
| import UploadForm from '@/components/upload/upload-form'; | ||
|
|
||
| export default async function UploadPage() { | ||
| const supabase = await createClient(); | ||
| const { | ||
| data: { user }, | ||
| } = await supabase.auth.getUser(); | ||
|
|
||
| if (!user) redirect('/login'); | ||
|
|
||
| return ( | ||
| <main> | ||
| <UploadForm /> | ||
| </main> | ||
| ); | ||
| } |
This file contains hidden or 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,31 @@ | ||
| "use client" | ||
|
|
||
| import * as React from "react" | ||
| import { Progress as ProgressPrimitive } from "radix-ui" | ||
|
|
||
| import { cn } from "@/lib/utils" | ||
|
|
||
| function Progress({ | ||
| className, | ||
| value, | ||
| ...props | ||
| }: React.ComponentProps<typeof ProgressPrimitive.Root>) { | ||
| return ( | ||
| <ProgressPrimitive.Root | ||
| data-slot="progress" | ||
| className={cn( | ||
| "relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted", | ||
| className | ||
| )} | ||
| {...props} | ||
| > | ||
| <ProgressPrimitive.Indicator | ||
| data-slot="progress-indicator" | ||
| className="size-full flex-1 bg-primary transition-all" | ||
| style={{ transform: `translateX(-${100 - (value || 0)}%)` }} | ||
| /> | ||
| </ProgressPrimitive.Root> | ||
| ) | ||
| } | ||
|
|
||
| export { Progress } |
This file contains hidden or 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,18 @@ | ||
| import * as React from "react" | ||
|
|
||
| import { cn } from "@/lib/utils" | ||
|
|
||
| function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { | ||
| return ( | ||
| <textarea | ||
| data-slot="textarea" | ||
| className={cn( | ||
| "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", | ||
| className | ||
| )} | ||
| {...props} | ||
| /> | ||
| ) | ||
| } | ||
|
|
||
| export { Textarea } |
This file contains hidden or 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,189 @@ | ||
| 'use client'; | ||
|
|
||
| import * as z from 'zod'; | ||
| import { Controller, useForm } from 'react-hook-form'; | ||
| import { zodResolver } from '@hookform/resolvers/zod'; | ||
|
|
||
| import { isContentType } from '@/lib/api/videos'; | ||
| import { UploadStatus, useVideoUpload } from '@/hooks/use-video-upload'; | ||
|
|
||
| import { | ||
| Card, | ||
| CardContent, | ||
| CardDescription, | ||
| CardHeader, | ||
| CardTitle, | ||
| } from '../ui/card'; | ||
| import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; | ||
| import { Input } from '../ui/input'; | ||
| import { Textarea } from '../ui/textarea'; | ||
| import { Progress } from '../ui/progress'; | ||
| import { Button } from '../ui/button'; | ||
|
|
||
| const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB | ||
|
|
||
| const uploadSchema = z.object({ | ||
| title: z | ||
| .string() | ||
| .min(1, 'Title is required') | ||
| .max(255, 'Title must be less than 255 characters'), | ||
| description: z | ||
| .string() | ||
| .max(5000, 'Description must be less than 5000 characters') | ||
| .optional(), | ||
| file: z | ||
| .instanceof(File, { message: 'File is required' }) | ||
| .refine((file) => file.size > 0, { | ||
| message: 'File appears to be empty', | ||
| }) | ||
| .refine((file) => file.size <= MAX_FILE_SIZE, { | ||
| message: 'File size must be less than 500MB', | ||
| }) | ||
| .refine((file) => isContentType(file.type), { | ||
| message: 'File type is not supported', | ||
| }), | ||
| }); | ||
|
|
||
| type UploadFormValues = z.infer<typeof uploadSchema>; | ||
|
|
||
| export default function UploadForm() { | ||
| const { uploadStatus, progress, error, upload } = useVideoUpload(); | ||
|
|
||
| const form = useForm<UploadFormValues>({ | ||
| resolver: zodResolver(uploadSchema), | ||
| defaultValues: { | ||
| title: '', | ||
| description: '', | ||
| file: undefined, | ||
| }, | ||
| }); | ||
|
|
||
| const isWorking = | ||
| uploadStatus === UploadStatus.CREATING || | ||
| uploadStatus === UploadStatus.UPLOADING || | ||
| uploadStatus === UploadStatus.PROCESSING; | ||
|
|
||
| const onSubmit = (data: UploadFormValues) => { | ||
| const { file, title, description } = data; | ||
|
|
||
| if (!isContentType(file.type)) return; | ||
| upload(file, { title, description, contentType: file.type }); | ||
| }; | ||
|
|
||
| // TODO: Currently dead end, need to implement a way to navigate to the uploaded video | ||
| if (uploadStatus === UploadStatus.READY) { | ||
| return ( | ||
| <Card> | ||
| <CardContent> | ||
| <p>Upload complete and processed!</p> | ||
| </CardContent> | ||
| </Card> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <Card> | ||
| <CardHeader> | ||
| <CardTitle>Upload Video</CardTitle> | ||
| <CardDescription>Upload your video file</CardDescription> | ||
| </CardHeader> | ||
| <CardContent> | ||
| <form onSubmit={form.handleSubmit(onSubmit)}> | ||
| <FieldGroup> | ||
| <Controller | ||
| name="title" | ||
| control={form.control} | ||
| render={({ field, fieldState }) => ( | ||
| <Field data-invalid={fieldState.invalid}> | ||
| <FieldLabel htmlFor="upload-title">Title</FieldLabel> | ||
| <Input | ||
| {...field} | ||
| id="upload-title" | ||
| placeholder="Title" | ||
| aria-invalid={fieldState.invalid} | ||
| disabled={isWorking} | ||
| /> | ||
| {fieldState.error && ( | ||
| <FieldError errors={[fieldState.error]} /> | ||
| )} | ||
| </Field> | ||
| )} | ||
| /> | ||
|
|
||
| <Controller | ||
| name="description" | ||
| control={form.control} | ||
| render={({ field, fieldState }) => ( | ||
| <Field data-invalid={fieldState.invalid}> | ||
| <FieldLabel htmlFor="upload-description"> | ||
| Description | ||
| </FieldLabel> | ||
| <Textarea | ||
| {...field} | ||
| id="upload-description" | ||
| placeholder="Description" | ||
| aria-invalid={fieldState.invalid} | ||
| disabled={isWorking} | ||
| /> | ||
| {fieldState.error && ( | ||
| <FieldError errors={[fieldState.error]} /> | ||
| )} | ||
| </Field> | ||
| )} | ||
| /> | ||
|
|
||
| <Controller | ||
| name="file" | ||
| control={form.control} | ||
| render={({ | ||
| field: { value, onChange, ...field }, | ||
| fieldState, | ||
| }) => ( | ||
| <Field data-invalid={fieldState.invalid}> | ||
| <FieldLabel htmlFor="upload-file">File</FieldLabel> | ||
| <Input | ||
| {...field} | ||
| type="file" | ||
| id="upload-file" | ||
| accept="video/*" | ||
| aria-invalid={fieldState.invalid} | ||
| disabled={isWorking} | ||
| onChange={(e) => onChange(e.target.files?.item(0))} | ||
| /> | ||
| {fieldState.error && ( | ||
| <FieldError errors={[fieldState.error]} /> | ||
| )} | ||
| </Field> | ||
| )} | ||
| /> | ||
| </FieldGroup> | ||
|
|
||
| {uploadStatus === UploadStatus.UPLOADING && ( | ||
| <div> | ||
| <Progress value={progress} /> | ||
| <p>{progress}% uploaded</p> | ||
| </div> | ||
| )} | ||
|
|
||
| {uploadStatus === UploadStatus.PROCESSING && ( | ||
| <p>Processing... this can take a minute</p> | ||
| )} | ||
|
|
||
| {error && ( | ||
| <p role="alert" className="text-destructive"> | ||
| {error} | ||
| </p> | ||
| )} | ||
|
|
||
| <Button | ||
| type="submit" | ||
| disabled={isWorking} | ||
| className="w-full mt-4 cursor-pointer" | ||
| > | ||
| {isWorking ? 'Uploading...' : 'Upload'} | ||
| </Button> | ||
| </form> | ||
| </CardContent> | ||
| </Card> | ||
| ); | ||
| } |
This file contains hidden or 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,118 @@ | ||
| import { | ||
| type ContentType, | ||
| createVideo, | ||
| getVideo, | ||
| updateVideoStatus, | ||
| VideoStatus, | ||
| } from '@/lib/api/videos'; | ||
| import { uploadToS3 } from '@/lib/upload/s3'; | ||
| import { useCallback, useEffect, useRef, useState } from 'react'; | ||
|
|
||
| export const UploadStatus = { | ||
| IDLE: 'idle', | ||
| CREATING: 'creating', | ||
| UPLOADING: 'uploading', | ||
| PROCESSING: 'processing', | ||
| READY: 'ready', | ||
| ERROR: 'error', | ||
| } as const; | ||
|
|
||
| export type UploadStatus = (typeof UploadStatus)[keyof typeof UploadStatus]; | ||
|
|
||
| interface UploadMetadata { | ||
| title: string; | ||
| description?: string; | ||
| contentType: ContentType; | ||
| } | ||
|
|
||
| const POLL_INTERVAL_MS = 2000; | ||
| const MAX_POLL_ATTEMPTS = 150; // ~5 min at 2s | ||
|
|
||
| export function useVideoUpload() { | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [progress, setProgress] = useState<number>(0); | ||
| const [uploadStatus, setUploadStatus] = useState<UploadStatus>( | ||
| UploadStatus.IDLE, | ||
| ); | ||
| const [playbackId, setPlaybackId] = useState<string | null>(null); | ||
| const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); | ||
|
|
||
| useEffect( | ||
| () => () => { | ||
| if (pollRef.current) { | ||
| clearInterval(pollRef.current); | ||
| } | ||
| }, | ||
| [], | ||
| ); | ||
|
|
||
| // Poll for video processing status to be `READY` or `FAILED` | ||
| const pollUntilReady = useCallback((videoId: string) => { | ||
| // Clear any existing interval before starting a new one | ||
| if (pollRef.current) clearInterval(pollRef.current); | ||
|
|
||
| let attempts = 0; // Bound the number of attempts | ||
|
|
||
| pollRef.current = setInterval(async () => { | ||
|
keanu-a marked this conversation as resolved.
keanu-a marked this conversation as resolved.
|
||
| // Setting ERROR if Mux outage or webhook failure | ||
| if (++attempts > MAX_POLL_ATTEMPTS) { | ||
| clearInterval(pollRef.current!); | ||
| setError('Processing timed out. Please try again.'); | ||
| setUploadStatus(UploadStatus.ERROR); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const video = await getVideo(videoId); | ||
| if (video.status === VideoStatus.READY) { | ||
| clearInterval(pollRef.current!); | ||
| setPlaybackId(video.playbackId); | ||
| setUploadStatus(UploadStatus.READY); | ||
| } else if (video.status === VideoStatus.FAILED) { | ||
| clearInterval(pollRef.current!); | ||
| setError('Error occurred while processing video'); | ||
| setUploadStatus(UploadStatus.ERROR); | ||
| } | ||
| } catch (e) { | ||
| clearInterval(pollRef.current!); | ||
| setError('Error occurred while polling video status'); | ||
| setUploadStatus(UploadStatus.ERROR); | ||
| } | ||
| }, POLL_INTERVAL_MS); | ||
| }, []); | ||
|
|
||
| const upload = useCallback( | ||
| async (file: File, metadata: UploadMetadata) => { | ||
| setError(null); | ||
| setProgress(0); | ||
|
|
||
| try { | ||
| // Initiate upload and get presigned PUT url | ||
| setUploadStatus(UploadStatus.CREATING); | ||
| const { videoId, presignedUrl } = await createVideo({ | ||
| title: metadata.title, | ||
| description: metadata.description, | ||
| fileName: file.name, | ||
| contentType: metadata.contentType, | ||
| }); | ||
|
|
||
| // Upload the file to S3 | ||
| setUploadStatus(UploadStatus.UPLOADING); | ||
| await uploadToS3(presignedUrl, file, setProgress); | ||
|
|
||
| // Update video status for Mux | ||
| await updateVideoStatus(videoId, VideoStatus.UPLOADED); | ||
| setUploadStatus(UploadStatus.PROCESSING); | ||
|
|
||
| // Poll for video processing status `READY` set by Mux | ||
| pollUntilReady(videoId); | ||
| } catch (e) { | ||
| setError('Error occurred while uploading video'); | ||
| setUploadStatus(UploadStatus.ERROR); | ||
| } | ||
| }, | ||
| [pollUntilReady], | ||
| ); | ||
|
|
||
| return { uploadStatus, progress, error, playbackId, upload }; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.