diff --git a/backend/src/main/java/com/kyyros/config/SecurityConfig.java b/backend/src/main/java/com/kyyros/config/SecurityConfig.java
index e757622..62d1858 100644
--- a/backend/src/main/java/com/kyyros/config/SecurityConfig.java
+++ b/backend/src/main/java/com/kyyros/config/SecurityConfig.java
@@ -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"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
diff --git a/frontend/src/app/upload/layout.tsx b/frontend/src/app/upload/layout.tsx
new file mode 100644
index 0000000..d7a4b0d
--- /dev/null
+++ b/frontend/src/app/upload/layout.tsx
@@ -0,0 +1,9 @@
+import { ReactNode } from 'react';
+
+export default function UploadLayout({ children }: { children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/app/upload/page.tsx b/frontend/src/app/upload/page.tsx
new file mode 100644
index 0000000..177cc16
--- /dev/null
+++ b/frontend/src/app/upload/page.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx
new file mode 100644
index 0000000..584011b
--- /dev/null
+++ b/frontend/src/components/ui/progress.tsx
@@ -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) {
+ return (
+
+
+
+ )
+}
+
+export { Progress }
diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..04d27f7
--- /dev/null
+++ b/frontend/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/frontend/src/components/upload/upload-form.tsx b/frontend/src/components/upload/upload-form.tsx
new file mode 100644
index 0000000..8961444
--- /dev/null
+++ b/frontend/src/components/upload/upload-form.tsx
@@ -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;
+
+export default function UploadForm() {
+ const { uploadStatus, progress, error, upload } = useVideoUpload();
+
+ const form = useForm({
+ 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 (
+
+
+ Upload complete and processed!
+
+
+ );
+ }
+
+ return (
+
+
+ Upload Video
+ Upload your video file
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/hooks/use-video-upload.ts b/frontend/src/hooks/use-video-upload.ts
new file mode 100644
index 0000000..ab72e8b
--- /dev/null
+++ b/frontend/src/hooks/use-video-upload.ts
@@ -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(null);
+ const [progress, setProgress] = useState(0);
+ const [uploadStatus, setUploadStatus] = useState(
+ UploadStatus.IDLE,
+ );
+ const [playbackId, setPlaybackId] = useState(null);
+ const pollRef = useRef | 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 () => {
+ // 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 };
+}
diff --git a/frontend/src/lib/api/fetcher.ts b/frontend/src/lib/api/fetcher.ts
new file mode 100644
index 0000000..02e7df5
--- /dev/null
+++ b/frontend/src/lib/api/fetcher.ts
@@ -0,0 +1,40 @@
+import { createClient } from '../supabase/client';
+
+const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
+
+if (!BASE_URL) {
+ throw new Error('NEXT_PUBLIC_API_URL is not defined');
+}
+
+// Reusable API fetcher function to grab token and attach it to the request
+export async function apiFetch(
+ path: string,
+ options: RequestInit = {},
+): Promise {
+ const supabase = createClient();
+ const {
+ data: { session },
+ } = await supabase.auth.getSession();
+
+ if (!session) throw new Error('Not authenticated');
+
+ const res = await fetch(`${BASE_URL}${path}`, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ Authorization: `Bearer ${session.access_token}`,
+ },
+ });
+
+ // TODO: Send better error messages to the client since body is not used
+ if (!res.ok) {
+ throw new Error(`Request failed: ${res.status}`);
+ }
+
+ if (res.status === 204) {
+ return null as unknown as T;
+ }
+
+ return res.json() as T;
+}
diff --git a/frontend/src/lib/api/videos.ts b/frontend/src/lib/api/videos.ts
new file mode 100644
index 0000000..a77120c
--- /dev/null
+++ b/frontend/src/lib/api/videos.ts
@@ -0,0 +1,72 @@
+import { apiFetch } from './fetcher';
+
+export const VideoStatus = {
+ PENDING: 'PENDING',
+ UPLOADED: 'UPLOADED',
+ PROCESSING: 'PROCESSING',
+ READY: 'READY',
+ FAILED: 'FAILED',
+} as const;
+
+export type VideoStatus = (typeof VideoStatus)[keyof typeof VideoStatus];
+
+export const CONTENT_TYPES = [
+ 'video/mp4',
+ 'video/quicktime',
+ 'video/webm',
+ 'video/x-matroska',
+ 'video/x-msvideo',
+] as const;
+
+export type ContentType = (typeof CONTENT_TYPES)[number];
+
+// Checks if provided string is a valid content type
+export function isContentType(value: string): value is ContentType {
+ return (CONTENT_TYPES as readonly string[]).includes(value);
+}
+
+export interface CreateVideoRequest {
+ title: string;
+ description?: string;
+ fileName: string;
+ contentType: ContentType;
+}
+
+export interface CreateVideoResponse {
+ videoId: string;
+ presignedUrl: string;
+ s3Key: string;
+}
+
+export interface GetVideoResponse {
+ id: string;
+ title: string;
+ status: VideoStatus;
+ playbackId: string | null;
+ createdAt: string;
+}
+
+export function createVideo(
+ request: CreateVideoRequest,
+): Promise {
+ return apiFetch('/api/v1/videos', {
+ method: 'POST',
+ body: JSON.stringify(request),
+ });
+}
+
+export function updateVideoStatus(
+ id: string,
+ videoStatus: VideoStatus,
+): Promise {
+ return apiFetch(`/api/v1/videos/${id}/status`, {
+ method: 'PATCH',
+ body: JSON.stringify({ videoStatus }),
+ });
+}
+
+export function getVideo(id: string): Promise {
+ return apiFetch(`/api/v1/videos/${id}`, {
+ method: 'GET',
+ });
+}
diff --git a/frontend/src/lib/upload/s3.ts b/frontend/src/lib/upload/s3.ts
new file mode 100644
index 0000000..b1cb884
--- /dev/null
+++ b/frontend/src/lib/upload/s3.ts
@@ -0,0 +1,31 @@
+export function uploadToS3(
+ presignedUrl: string,
+ file: File,
+ onProgress?: (percent: number) => void,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('PUT', presignedUrl, true);
+ xhr.setRequestHeader('Content-Type', file.type);
+
+ xhr.upload.onprogress = (e) => {
+ if (onProgress && e.lengthComputable) {
+ onProgress(Math.round((e.loaded / e.total) * 100));
+ }
+ };
+
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve();
+ } else {
+ reject(new Error(`S3 upload failed: ${xhr.status}`));
+ }
+ };
+
+ xhr.onerror = () => {
+ reject(new Error('S3 upload failed'));
+ };
+
+ xhr.send(file);
+ });
+}