From a134fa51c42529ac51d99e4fda019aa3371ab92e Mon Sep 17 00:00:00 2001 From: keanu-a Date: Mon, 22 Jun 2026 12:08:41 -0700 Subject: [PATCH 1/6] feat(frontend): create useVideoUpload hook and api util functions --- frontend/src/hooks/use-video-upload.ts | 100 +++++++++++++++++++++++++ frontend/src/lib/api/fetcher.ts | 35 +++++++++ frontend/src/lib/api/videos.ts | 64 ++++++++++++++++ frontend/src/lib/upload/s3.ts | 31 ++++++++ 4 files changed, 230 insertions(+) create mode 100644 frontend/src/hooks/use-video-upload.ts create mode 100644 frontend/src/lib/api/fetcher.ts create mode 100644 frontend/src/lib/api/videos.ts create mode 100644 frontend/src/lib/upload/s3.ts diff --git a/frontend/src/hooks/use-video-upload.ts b/frontend/src/hooks/use-video-upload.ts new file mode 100644 index 0000000..9d489ab --- /dev/null +++ b/frontend/src/hooks/use-video-upload.ts @@ -0,0 +1,100 @@ +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 type UploadStatus = + | 'idle' + | 'creating' + | 'uploading' + | 'processing' + | 'ready' + | 'error'; + +interface UploadMetadata { + title: string; + description?: string; + contentType: ContentType; +} + +const POLL_INTERVAL_MS = 2000; + +export function useVideoUpload() { + const [error, setError] = useState(null); + const [progress, setProgress] = useState(0); + const [status, setStatus] = useState('idle'); + const [playbackId, setPlaybackId] = useState(null); + const pollRef = useRef | null>(null); + + useEffect( + () => () => { + if (pollRef.current) { + clearInterval(pollRef.current); + } + }, + [], + ); + + const pollUntilReady = useCallback((videoId: string) => { + // Poll for video processing status to be `READY` or `FAILED` + pollRef.current = setInterval(async () => { + try { + const video = await getVideo(videoId); + if (video.status === VideoStatus.READY) { + clearInterval(pollRef.current!); + setPlaybackId(video.playbackId); + setStatus('ready'); + } else if (video.status === VideoStatus.FAILED) { + clearInterval(pollRef.current!); + setError('Error occurred while processing video'); + setStatus('error'); + } + } catch (e) { + clearInterval(pollRef.current!); + setError('Error occurred while polling video status'); + setStatus('error'); + } + }, POLL_INTERVAL_MS); + }, []); + + const upload = useCallback( + async (file: File, metadata: UploadMetadata) => { + setError(null); + setProgress(0); + + try { + // Initiate upload and get presigned PUT url + setStatus('creating'); + const { videoId, presignedUrl } = await createVideo({ + title: metadata.title, + description: metadata.description, + fileName: file.name, + contentType: metadata.contentType, + }); + + // Upload the file to S3 + setStatus('uploading'); + await uploadToS3(presignedUrl, file, setProgress); + + // Update video status for Mux + await updateVideoStatus(videoId, VideoStatus.UPLOADED); + setStatus('processing'); + + // Poll for video processing status `READY` set by Mux + pollUntilReady(videoId); + } catch (e) { + console.error(e); // TODO: For dev purposes only + setError('Error occurred while uploading video'); + setStatus('error'); + } + }, + [pollUntilReady], + ); + + return { status, 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..ebc3c4a --- /dev/null +++ b/frontend/src/lib/api/fetcher.ts @@ -0,0 +1,35 @@ +import { createClient } from '../supabase/client'; + +const BASE_URL = process.env.NEXT_PUBLIC_API_URL; + +// 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}`, + }, + }); + + 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..943e104 --- /dev/null +++ b/frontend/src/lib/api/videos.ts @@ -0,0 +1,64 @@ +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 type ContentType = + | 'video/mp4' + | 'video/quicktime' + | 'video/webm' + | 'video/x-matroska' + | 'video/x-msvideo'; + +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; + createdAt: string; +} + +export function createVideo( + request: CreateVideoRequest, +): Promise { + return apiFetch('/api/v1/videos', { + method: 'POST', + body: JSON.stringify(request), + }); +} + +export function updateVideoStatus( + id: string, + status: VideoStatus, +): Promise { + return apiFetch(`/api/v1/videos/${id}`, { + method: 'PATCH', + body: JSON.stringify({ status }), + }); +} + +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); + }); +} From 159ea3627a7d6c38d2e53dbe61c3947ea08a4e45 Mon Sep 17 00:00:00 2001 From: keanu-a Date: Mon, 22 Jun 2026 13:48:39 -0700 Subject: [PATCH 2/6] feat(frontend): create upload video form and page --- frontend/src/app/upload/layout.tsx | 9 + frontend/src/app/upload/page.tsx | 19 ++ frontend/src/components/ui/progress.tsx | 31 +++ frontend/src/components/ui/textarea.tsx | 18 ++ .../src/components/upload/upload-form.tsx | 185 ++++++++++++++++++ frontend/src/hooks/use-video-upload.ts | 38 ++-- frontend/src/lib/api/videos.ts | 26 ++- 7 files changed, 300 insertions(+), 26 deletions(-) create mode 100644 frontend/src/app/upload/layout.tsx create mode 100644 frontend/src/app/upload/page.tsx create mode 100644 frontend/src/components/ui/progress.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/components/upload/upload-form.tsx 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 ( +
+
{children}
+
+ ); +} 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 ( +