From 2920355ae0cb03f7d5d8e666b7e8419e8c67c3ff Mon Sep 17 00:00:00 2001 From: NaFo44 <132383273+NaFo44@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:43:17 +0200 Subject: [PATCH] fix(upload): chunks upload instead of multipart --- README.md | 2 +- app/upload/page.tsx | 77 ++++++- app/upload/upload-api.ts | 197 +++++++++++++++--- app/upload/upload-constants.ts | 8 + app/upload/upload-helpers.ts | 26 +-- .../marketing/sections/FeatureSection.tsx | 4 +- components/marketing/sections/HeroSection.tsx | 4 +- docs/ARCHITECTURE.md | 8 +- package-lock.json | 37 ++-- 9 files changed, 286 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 5dde395..36ca6db 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ docs/ Repository-level technical documentation - Sensitive areas are checked server-side against the backend session and reinforced client-side with sanitized callback URLs. - Protected routes distinguish between an expired session and a temporarily unavailable auth backend, redirecting the latter to a dedicated recovery screen instead of treating it as a logout. - Client-side auth hydration preserves a distinct "service unavailable" state so public surfaces do not misleadingly fall back to login prompts when the auth backend is down. -- Uploads are sent as multipart form data to the backend. +- Large video uploads automatically use the backend chunked-upload flow instead of a single monolithic request. For a more detailed technical walkthrough, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). diff --git a/app/upload/page.tsx b/app/upload/page.tsx index 34bf279..b93310d 100644 --- a/app/upload/page.tsx +++ b/app/upload/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { useForm } from "react-hook-form"; import { useRouter } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -15,7 +15,15 @@ import ThumbnailDropzone from "./components/thumbnail-dropzone"; import UploadProgress from "./components/upload-progress"; import UploadDetailsFormFields from "./components/details-fields"; import UploadStepScreen from "./components/upload-step"; -import { UPLOAD_STEPS, type UploadStep } from "./upload-constants"; +import { + DEFAULT_UPLOADING_LABEL, + MAX_THUMBNAIL_BYTES, + MAX_THUMBNAIL_MB, + MAX_VIDEO_UPLOAD_TOTAL_BYTES, + MAX_VIDEO_UPLOAD_TOTAL_MB, + UPLOAD_STEPS, + type UploadStep, +} from "./upload-constants"; import { uploadVideo } from "./upload-api"; import { createIdleUploadRequestState, @@ -50,6 +58,7 @@ export default function UploadPage() { const [thumbnailFile, setThumbnailFile] = useState(null); const [thumbnailError, setThumbnailError] = useState(null); const [currentStep, setCurrentStep] = useState(1); + const uploadAbortControllerRef = useRef(null); const { register, @@ -82,6 +91,13 @@ export default function UploadPage() { reset(); }; + useEffect(() => { + return () => { + uploadAbortControllerRef.current?.abort(); + uploadAbortControllerRef.current = null; + }; + }, []); + const handleFileSelect = (nextFile: File | null) => { resetUploadSession(); setCurrentStep(1); @@ -98,6 +114,12 @@ export default function UploadPage() { return; } + if (nextFile.size > MAX_VIDEO_UPLOAD_TOTAL_BYTES) { + setFile(null); + setFileError(`Videos are limited to ${MAX_VIDEO_UPLOAD_TOTAL_MB} MB.`); + return; + } + setFile(nextFile); setFileError(null); setCurrentStep(2); @@ -116,6 +138,12 @@ export default function UploadPage() { return; } + if (nextFile.size > MAX_THUMBNAIL_BYTES) { + setThumbnailFile(null); + setThumbnailError(`Thumbnails are limited to ${MAX_THUMBNAIL_MB} MB.`); + return; + } + setThumbnailFile(nextFile); setThumbnailError(null); }; @@ -128,19 +156,37 @@ export default function UploadPage() { } setCurrentStep(4); - setVideoRequest({ state: "uploading", progress: 0, error: null }); + setVideoRequest({ + state: "uploading", + progress: 0, + error: null, + uploadingLabel: DEFAULT_UPLOADING_LABEL, + }); try { + const controller = new AbortController(); + uploadAbortControllerRef.current = controller; + await uploadVideo({ file, thumbnail: thumbnailFile, values: data, - onProgress: (progress) => { - setVideoRequest((prev) => ({ ...prev, progress })); + signal: controller.signal, + onProgress: ({ progress, label }) => { + setVideoRequest((prev) => ({ + ...prev, + progress, + uploadingLabel: label, + })); }, }); - setVideoRequest({ state: "done", progress: 100, error: null }); + setVideoRequest({ + state: "done", + progress: 100, + error: null, + uploadingLabel: DEFAULT_UPLOADING_LABEL, + }); toast.success( thumbnailFile @@ -149,8 +195,15 @@ export default function UploadPage() { ); } catch (error) { const message = resolveUploadErrorMessage(error); - setVideoRequest((prev) => ({ ...prev, state: "error", error: message })); + setVideoRequest((prev) => ({ + ...prev, + state: "error", + error: message, + uploadingLabel: DEFAULT_UPLOADING_LABEL, + })); toast.error(message); + } finally { + uploadAbortControllerRef.current = null; } }; @@ -277,6 +330,16 @@ export default function UploadPage() { case 4: return ( <> + {isUploading && ( + + )} {videoRequest.state === "error" && (