Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000")); // TODO: Add Vercel URL in production
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
Comment thread
keanu-a marked this conversation as resolved.
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);

Expand Down
9 changes: 9 additions & 0 deletions frontend/src/app/upload/layout.tsx
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>
);
}
19 changes: 19 additions & 0 deletions frontend/src/app/upload/page.tsx
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>
);
}
31 changes: 31 additions & 0 deletions frontend/src/components/ui/progress.tsx
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 }
18 changes: 18 additions & 0 deletions frontend/src/components/ui/textarea.tsx
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 }
189 changes: 189 additions & 0 deletions frontend/src/components/upload/upload-form.tsx
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>
);
}
118 changes: 118 additions & 0 deletions frontend/src/hooks/use-video-upload.ts
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 () => {
Comment thread
keanu-a marked this conversation as resolved.
Comment thread
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 };
}
Loading