From 1e38e28e72b4967f066f0f5e7f9643a12278dc0d Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Fri, 19 Sep 2025 18:29:27 +0800 Subject: [PATCH 01/19] fix --- apps/desktop/src-tauri/src/upload.rs | 15 ++++--- apps/web/app/api/desktop/[...route]/video.ts | 46 +++++++++++++++++--- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 91b442dc6..f6e923697 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -186,12 +186,15 @@ impl UploadProgressUpdater { async fn send_api_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) { let response = app .authed_api_request("/api/desktop/video/progress", |client, url| { - client.post(url).json(&json!({ - "videoId": video_id, - "uploaded": uploaded, - "total": total, - "updatedAt": chrono::Utc::now().to_rfc3339() - })) + client + .post(url) + .header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION")) + .json(&json!({ + "videoId": video_id, + "uploaded": uploaded, + "total": total, + "updatedAt": chrono::Utc::now().to_rfc3339() + })) }) .await; diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index d21141e41..02900ed16 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -125,9 +125,15 @@ app.get( fps, }); - await db().insert(videoUploads).values({ - videoId: idToUse, - }); + const xCapVersion = c.req.header("X-Cap-Desktop-Version"); + const clientSupportsUploadProgress = xCapVersion + ? isGreaterThanSemver(xCapVersion, 0, 3, 68) + : false; + + if (clientSupportsUploadProgress) + await db().insert(videoUploads).values({ + videoId: idToUse, + }); if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") await dub().links.create({ @@ -291,14 +297,13 @@ app.post( ), ); - if (result.rowsAffected === 0) { - const result2 = await db().insert(videoUploads).values({ + if (result.rowsAffected === 0) + await db().insert(videoUploads).values({ videoId, uploaded, total, updatedAt, }); - } if (uploaded === total) await db() @@ -312,3 +317,32 @@ app.post( } }, ); + +function isGreaterThanSemver( + versionString: string, + major: number, + minor: number, + patch: number, +): boolean { + // Parse version string, remove 'v' prefix if present + const match = versionString + .replace(/^v/, "") + .match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?/); + + if (!match) { + throw new Error(`Invalid semver version: ${versionString}`); + } + + const [, vMajor, vMinor, vPatch, prerelease] = match; + const parsedMajor = parseInt(vMajor, 10); + const parsedMinor = parseInt(vMinor, 10); + const parsedPatch = parseInt(vPatch, 10); + + // Compare major.minor.patch + if (parsedMajor !== major) return parsedMajor > major; + if (parsedMinor !== minor) return parsedMinor > minor; + if (parsedPatch !== patch) return parsedPatch > patch; + + // If versions are equal, prerelease versions have lower precedence + return !prerelease; // true if no prerelease (1.0.0 > 1.0.0-alpha), false if prerelease +} From 1a602df9996f58ee3ff3aa8fd96970130d0336fd Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Fri, 19 Sep 2025 18:45:31 +0800 Subject: [PATCH 02/19] create video upload entry for via web --- apps/web/actions/video/upload.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index d6dc394a3..c77820242 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -7,7 +7,7 @@ import { import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; -import { s3Buckets, videos } from "@cap/database/schema"; +import { s3Buckets, videos, videoUploads } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { userIsPro } from "@cap/utils"; import { eq } from "drizzle-orm"; @@ -231,6 +231,10 @@ export async function createVideoAndGetUploadUrl({ await db().insert(videos).values(videoData); + await db().insert(videoUploads).values({ + videoId: idToUse, + }); + const fileKey = `${user.id}/${idToUse}/${ isScreenshot ? "screenshot/screen-capture.jpg" : "result.mp4" }`; From 826b27a1a4cea016b5e9af4a85c5b18b331948d0 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Fri, 19 Sep 2025 19:17:23 +0800 Subject: [PATCH 03/19] yeet --- apps/web/app/api/desktop/[...route]/video.ts | 6 +++--- packages/utils/src/helpers.ts | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 02900ed16..b3cd427f0 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -334,9 +334,9 @@ function isGreaterThanSemver( } const [, vMajor, vMinor, vPatch, prerelease] = match; - const parsedMajor = parseInt(vMajor, 10); - const parsedMinor = parseInt(vMinor, 10); - const parsedPatch = parseInt(vPatch, 10); + const parsedMajor = vMajor ? parseInt(vMajor, 10) : 0; + const parsedMinor = vMinor ? parseInt(vMinor, 10) : 0; + const parsedPatch = vPatch ? parseInt(vPatch, 10) : 0; // Compare major.minor.patch if (parsedMajor !== major) return parsedMajor > major; diff --git a/packages/utils/src/helpers.ts b/packages/utils/src/helpers.ts index 85f5c3eea..7ee4ab6a2 100644 --- a/packages/utils/src/helpers.ts +++ b/packages/utils/src/helpers.ts @@ -75,9 +75,7 @@ export const calculateStrokeDashoffset = ( }; export const getUploadStatus = (uploadProgress?: number) => { - if (uploadProgress !== undefined) { - return "Uploading"; - } + if (uploadProgress !== undefined) return "Uploading"; return "Processing"; }; From b14adac91a7fd46f40b8bd1cf37234690401eecf Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 21 Sep 2025 22:23:59 +0800 Subject: [PATCH 04/19] a mess --- apps/web/app/(org)/dashboard/caps/Caps.tsx | 56 +- .../(org)/dashboard/caps/UploadingContext.tsx | 478 +++++++++++++++++- .../(org)/dashboard/caps/components/index.ts | 2 +- apps/web/app/(org)/dashboard/caps/uploader.ts | 21 + .../[id]/components/FolderVideosSection.tsx | 27 +- apps/web/components/VideoThumbnail.tsx | 8 +- 6 files changed, 547 insertions(+), 45 deletions(-) create mode 100644 apps/web/app/(org)/dashboard/caps/uploader.ts diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index 1623cb271..4d5e27a56 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -75,12 +75,12 @@ export const Caps = ({ const [selectedCaps, setSelectedCaps] = useState([]); const [isDraggingCap, setIsDraggingCap] = useState(false); const { - isUploading, - setIsUploading, - setUploadingCapId, - setUploadProgress, - uploadingCapId, - setUploadingThumbnailUrl, + // isUploading, + // setIsUploading, + // setUploadingCapId, + // setUploadProgress, + // uploadingCapId, + // setUploadingThumbnailUrl, } = useUploadingContext(); const anyCapSelected = selectedCaps.length > 0; @@ -267,13 +267,15 @@ export const Caps = ({ }, }); - const visibleVideos = useMemo( - () => - isUploading && uploadingCapId - ? data.filter((video) => video.id !== uploadingCapId) - : data, - [data, isUploading, uploadingCapId], - ); + const visibleVideos = data; // TODO: Remove this + + // const visibleVideos = useMemo( + // () => + // isUploading && uploadingCapId + // ? data.filter((video) => video.id !== uploadingCapId) + // : data, + // [data, isUploading, uploadingCapId], + // ); if (count === 0) return ; @@ -294,19 +296,19 @@ export const Caps = ({ New Folder { - setIsUploading(true); - setUploadingCapId(id); - setUploadingThumbnailUrl(thumbnailUrl); - setUploadProgress(0); - }} + // onStart={(id, thumbnailUrl) => { + // setIsUploading(true); + // setUploadingCapId(id); + // setUploadingThumbnailUrl(thumbnailUrl); + // setUploadProgress(0); + // }} size="sm" - onComplete={() => { - setIsUploading(false); - setUploadingCapId(null); - setUploadingThumbnailUrl(undefined); - setUploadProgress(0); - }} + // onComplete={() => { + // setIsUploading(false); + // setUploadingCapId(null); + // setUploadingThumbnailUrl(undefined); + // setUploadProgress(0); + // }} /> {folders.length > 0 && ( @@ -328,9 +330,9 @@ export const Caps = ({
- {isUploading && ( + {/*{isUploading && ( - )} + )}*/} {visibleVideos.map((video) => { return ( ; + lastUpdateTime: number; +}; + +type DoUploadOptions = { + onStart?: (id: string, thumbnail?: string) => void; + onProgress?: (id: string, progress: number, uploadProgress?: number) => void; + onComplete?: (id: string) => void; +}; interface UploadingContextType { isUploading: boolean; @@ -12,6 +29,15 @@ interface UploadingContextType { setUploadingThumbnailUrl: (url: string | undefined) => void; uploadProgress: number; setUploadProgress: (progress: number) => void; + + // TODO + doUpload: ( + file: File, + folderId: string | undefined, + // TODO: I wonder if we can do this better. + // This is designed to ensure the old code is compatible. + options?: DoUploadOptions, + ) => Promise; } const UploadingContext = createContext( @@ -35,6 +61,103 @@ export function UploadingProvider({ children }: { children: React.ReactNode }) { string | undefined >(undefined); const [uploadProgress, setUploadProgress] = useState(0); + const router = useRouter(); + + const [uploadState, setUploadState] = useState({ + uploaded: 0, + total: 0, + lastUpdateTime: Date.now(), + }); + + const sendProgressUpdate = async ( + videoId: string, + uploaded: number, + total: number, + ) => { + try { + const response = await fetch("/api/desktop/video/progress", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + videoId, + uploaded, + total, + updatedAt: new Date().toISOString(), + }), + }); + + if (!response.ok) + console.error("Failed to send progress update:", response.status); + } catch (err) { + console.error("Error sending progress update:", err); + } + }; + + // Prevent the user closing the tab while uploading + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (isUploading) { + e.preventDefault(); + // Chrome requires returnValue to be set + e.returnValue = ""; + return ""; + } + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [isUploading]); + + useEffect(() => { + if (!uploadState.videoId || uploadState.uploaded === 0 || !isUploading) + return; + + // Clear any existing pending task + if (uploadState.pendingTask) clearTimeout(uploadState.pendingTask); + + const shouldSendImmediately = uploadState.uploaded >= uploadState.total; + + if (shouldSendImmediately) { + // Send completion update immediately and clear state + sendProgressUpdate( + uploadState.videoId, + uploadState.uploaded, + uploadState.total, + ); + + setUploadState((prev) => ({ + ...prev, + pendingTask: undefined, + })); + } else { + // Schedule delayed update (after 2 seconds) + const newPendingTask = setTimeout(() => { + if (uploadState.videoId) { + sendProgressUpdate( + uploadState.videoId, + uploadState.uploaded, + uploadState.total, + ); + } + }, 2000); + + setUploadState((prev) => ({ + ...prev, + pendingTask: newPendingTask, + })); + } + + return () => { + if (uploadState.pendingTask) clearTimeout(uploadState.pendingTask); + }; + }, [ + uploadState.videoId, + uploadState.uploaded, + uploadState.total, + isUploading, + ]); return ( { + setIsUploading(true); + setUploadProgress(0); + try { + const parser = await import("@remotion/media-parser"); + const webcodecs = await import("@remotion/webcodecs"); + + const metadata = await parser.parseMedia({ + src: file, + fields: { + durationInSeconds: true, + dimensions: true, + fps: true, + numberOfAudioChannels: true, + sampleRate: true, + }, + }); + + const duration = metadata.durationInSeconds + ? Math.round(metadata.durationInSeconds) + : undefined; + + const videoData = await createVideoAndGetUploadUrl({ + duration, + resolution: metadata.dimensions + ? `${metadata.dimensions.width}x${metadata.dimensions.height}` + : undefined, + videoCodec: "h264", + audioCodec: "aac", + isScreenshot: false, + isUpload: true, + folderId, + }); + + const uploadId = videoData.id; + // Initial start with thumbnail as undefined + onStart?.(uploadId); + onProgress?.(uploadId, 10); + + const fileSizeMB = file.size / (1024 * 1024); + onProgress?.(uploadId, 15); + + let optimizedBlob: Blob; + + try { + const calculateResizeOptions = () => { + if (!metadata.dimensions) return undefined; + + const { width, height } = metadata.dimensions; + const maxWidth = 1920; + const maxHeight = 1080; + + if (width <= maxWidth && height <= maxHeight) { + return undefined; + } + + const widthScale = maxWidth / width; + const heightScale = maxHeight / height; + const scale = Math.min(widthScale, heightScale); + + return { mode: "scale" as const, scale }; + }; + + const resizeOptions = calculateResizeOptions(); + + const convertResult = await webcodecs.convertMedia({ + src: file, + container: "mp4", + videoCodec: "h264", + audioCodec: "aac", + ...(resizeOptions && { resize: resizeOptions }), + onProgress: ({ overallProgress }) => { + if (overallProgress !== null) { + const progressValue = overallProgress * 100; + onProgress?.(uploadId, progressValue); + } + }, + }); + optimizedBlob = await convertResult.save(); + + if (optimizedBlob.size === 0) { + throw new Error("Conversion produced empty file"); + } + const isValidVideo = await new Promise((resolve) => { + const testVideo = document.createElement("video"); + testVideo.muted = true; + testVideo.playsInline = true; + testVideo.preload = "metadata"; + + const timeout = setTimeout(() => { + console.warn("Video validation timed out"); + URL.revokeObjectURL(testVideo.src); + resolve(false); + }, 15000); + + let metadataLoaded = false; + + const validateVideo = () => { + if (metadataLoaded) return; + metadataLoaded = true; + + const hasValidDuration = + testVideo.duration > 0 && + !isNaN(testVideo.duration) && + isFinite(testVideo.duration); + + const hasValidDimensions = + (testVideo.videoWidth > 0 && testVideo.videoHeight > 0) || + (metadata.dimensions && + metadata.dimensions.width > 0 && + metadata.dimensions.height > 0); + + if (hasValidDuration && hasValidDimensions) { + clearTimeout(timeout); + URL.revokeObjectURL(testVideo.src); + resolve(true); + } else { + console.warn( + `Invalid video properties - Duration: ${testVideo.duration}, Dimensions: ${testVideo.videoWidth}x${testVideo.videoHeight}, Original dimensions: ${metadata.dimensions?.width}x${metadata.dimensions?.height}`, + ); + clearTimeout(timeout); + URL.revokeObjectURL(testVideo.src); + resolve(false); + } + }; + + testVideo.addEventListener("loadedmetadata", validateVideo); + testVideo.addEventListener("loadeddata", validateVideo); + testVideo.addEventListener("canplay", validateVideo); + + testVideo.addEventListener("error", (e) => { + console.error("Video validation error:", e); + clearTimeout(timeout); + URL.revokeObjectURL(testVideo.src); + resolve(false); + }); + + testVideo.addEventListener("loadstart", () => {}); + + testVideo.src = URL.createObjectURL(optimizedBlob); + }); + + if (!isValidVideo) { + throw new Error("Converted video is not playable"); + } + } catch (conversionError) { + console.error("Video conversion failed:", conversionError); + toast.error( + "Failed to process video file. This format may not be supported for upload.", + ); + return; + } + + const captureThumbnail = (): Promise => { + return new Promise((resolve) => { + const video = document.createElement("video"); + video.src = URL.createObjectURL(optimizedBlob); + video.muted = true; + video.playsInline = true; + video.crossOrigin = "anonymous"; + + const cleanup = () => { + URL.revokeObjectURL(video.src); + }; + + const timeout = setTimeout(() => { + cleanup(); + console.warn( + "Thumbnail generation timed out, proceeding without thumbnail", + ); + resolve(null); + }, 10000); + + video.addEventListener("loadedmetadata", () => { + try { + const seekTime = Math.min(1, video.duration / 4); + video.currentTime = seekTime; + } catch (err) { + console.warn("Failed to seek video for thumbnail:", err); + clearTimeout(timeout); + cleanup(); + resolve(null); + } + }); + + video.addEventListener("seeked", () => { + try { + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth || 640; + canvas.height = video.videoHeight || 480; + const ctx = canvas.getContext("2d"); + if (!ctx) { + console.warn("Failed to get canvas context"); + clearTimeout(timeout); + cleanup(); + resolve(null); + return; + } + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + canvas.toBlob( + (blob) => { + clearTimeout(timeout); + cleanup(); + if (blob) { + resolve(blob); + } else { + console.warn("Failed to create thumbnail blob"); + resolve(null); + } + }, + "image/jpeg", + 0.8, + ); + } catch (err) { + console.warn("Error during thumbnail capture:", err); + clearTimeout(timeout); + cleanup(); + resolve(null); + } + }); + + video.addEventListener("error", (err) => { + console.warn("Video loading error for thumbnail:", err); + clearTimeout(timeout); + cleanup(); + resolve(null); + }); + + video.addEventListener("loadstart", () => {}); + }); + }; + + const thumbnailBlob = await captureThumbnail(); + const thumbnailUrl = thumbnailBlob + ? URL.createObjectURL(thumbnailBlob) + : undefined; + + // Pass the thumbnail URL to the parent component + onStart?.(uploadId, thumbnailUrl); + onProgress?.(uploadId, 100); + + const formData = new FormData(); + Object.entries(videoData.presignedPostData.fields).forEach( + ([key, value]) => { + formData.append(key, value as string); + }, + ); + formData.append("file", optimizedBlob); + + setUploadProgress(0); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", videoData.presignedPostData.url); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percent = (event.loaded / event.total) * 100; + setUploadProgress(percent); + onProgress?.(uploadId, 100, percent); + + setUploadState({ + videoId: uploadId, + uploaded: event.loaded, + total: event.total, + lastUpdateTime: Date.now(), + pendingTask: uploadState.pendingTask, + }); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + setUploadProgress(100); + onProgress?.(uploadId, 100, 100); + // sendProgressUpdate(uploadId, 100, 100); // TODO + resolve(); + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)); + } + }; + xhr.onerror = () => reject(new Error("Upload failed")); + + xhr.send(formData); + }); + + if (thumbnailBlob) { + const screenshotData = await createVideoAndGetUploadUrl({ + videoId: uploadId, + isScreenshot: true, + isUpload: true, + }); + + const screenshotFormData = new FormData(); + Object.entries(screenshotData.presignedPostData.fields).forEach( + ([key, value]) => { + screenshotFormData.append(key, value as string); + }, + ); + screenshotFormData.append("file", thumbnailBlob); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", screenshotData.presignedPostData.url); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percent = (event.loaded / event.total) * 100; + // Thumbnail upload is a small additional step, keep at 100% + onProgress?.(uploadId, 100, 100); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + onProgress?.(uploadId, 100, 100); + resolve(); + } else { + reject( + new Error( + `Screenshot upload failed with status ${xhr.status}`, + ), + ); + } + }; + xhr.onerror = () => + reject(new Error("Screenshot upload failed")); + + xhr.send(screenshotFormData); + }); + } + + // Final progress update to ensure we're at 100% + setUploadProgress(100); + onProgress?.(uploadId, 100, 100); + onComplete?.(uploadId); + router.refresh(); + } catch (err) { + console.error("Video upload failed", err); + } finally { + setIsUploading(false); + setUploadProgress(0); + setUploadState({ + uploaded: 0, + total: 0, + lastUpdateTime: Date.now(), + }); + } + }, }} > {children} diff --git a/apps/web/app/(org)/dashboard/caps/components/index.ts b/apps/web/app/(org)/dashboard/caps/components/index.ts index ced28c433..5c9758b5f 100644 --- a/apps/web/app/(org)/dashboard/caps/components/index.ts +++ b/apps/web/app/(org)/dashboard/caps/components/index.ts @@ -4,4 +4,4 @@ export * from "./Folder"; export * from "./NewFolderDialog"; export * from "./SelectedCapsBar"; export * from "./UploadCapButton"; -export * from "./UploadPlaceholderCard"; +// export * from "./UploadPlaceholderCard"; diff --git a/apps/web/app/(org)/dashboard/caps/uploader.ts b/apps/web/app/(org)/dashboard/caps/uploader.ts new file mode 100644 index 000000000..7828859b5 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/uploader.ts @@ -0,0 +1,21 @@ +// import { mutationOptions } from "@tanstack/react-query"; + +import { MutationOptions } from "@tanstack/react-query"; + +// const todo = mutationOptions({}); + +const todo = { + mutationKey: ["todo"], + mutationFn: async (data: any) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return "bruh"; + }, +} satisfies MutationOptions; + +export function useUploadCap() { + // TODO +} + +export function useUploadCapStatus() { + // TODO +} diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx index 1228636d2..184b3cbe2 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx @@ -12,7 +12,7 @@ import { Rpc, withRpc } from "@/lib/Rpcs"; import type { VideoData } from "../../../caps/Caps"; import { CapCard } from "../../../caps/components/CapCard/CapCard"; import { SelectedCapsBar } from "../../../caps/components/SelectedCapsBar"; -import { UploadPlaceholderCard } from "../../../caps/components/UploadPlaceholderCard"; +// import { UploadPlaceholderCard } from "../../../caps/components/UploadPlaceholderCard"; import { useUploadingContext } from "../../../caps/UploadingContext"; interface FolderVideosSectionProps { @@ -27,7 +27,9 @@ export default function FolderVideosSection({ cardType = "default", }: FolderVideosSectionProps) { const router = useRouter(); - const { isUploading, uploadingCapId } = useUploadingContext(); + const { + // isUploading, uploadingCapId + } = useUploadingContext(); const { user } = useDashboardContext(); const [selectedCaps, setSelectedCaps] = useState([]); @@ -155,13 +157,14 @@ export default function FolderVideosSection({ refetchOnMount: true, }); - const visibleVideos = useMemo( - () => - isUploading && uploadingCapId - ? initialVideos.filter((video) => video.id !== uploadingCapId) - : initialVideos, - [initialVideos, isUploading, uploadingCapId], - ); + const visibleVideos = initialVideos; + // const visibleVideos = useMemo( + // () => + // isUploading && uploadingCapId + // ? initialVideos.filter((video) => video.id !== uploadingCapId) + // : initialVideos, + // [initialVideos, isUploading, uploadingCapId], + // ); const analytics = analyticsData || {}; @@ -171,16 +174,16 @@ export default function FolderVideosSection({

Videos

- {visibleVideos.length === 0 && !isUploading ? ( + {visibleVideos.length === 0 ? (

No videos in this folder yet. Drag and drop into the folder or upload.

) : ( <> - {isUploading && ( + {/*{isUploading && ( - )} + )}*/} {visibleVideos.map((video) => ( = memo( }); const imageRef = useRef(null); - const { uploadingCapId } = useUploadingContext(); + // const { uploadingCapId } = useUploadingContext(); - useEffect(() => { - imageUrl.refetch(); - }, [imageUrl.refetch, uploadingCapId]); + // useEffect(() => { + // imageUrl.refetch(); + // }, [imageUrl.refetch, uploadingCapId]); const randomGradient = `linear-gradient(to right, ${generateRandomGrayScaleColor()}, ${generateRandomGrayScaleColor()})`; From 7c0dd4fa812e1f295470997e4454d0ad1cc651ae Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 21 Sep 2025 22:32:16 +0800 Subject: [PATCH 05/19] reset --- apps/web/app/(org)/dashboard/caps/Caps.tsx | 56 +- .../(org)/dashboard/caps/UploadingContext.tsx | 478 +----------------- .../(org)/dashboard/caps/components/index.ts | 2 +- apps/web/app/(org)/dashboard/caps/uploader.ts | 21 - .../[id]/components/FolderVideosSection.tsx | 27 +- apps/web/components/VideoThumbnail.tsx | 8 +- 6 files changed, 45 insertions(+), 547 deletions(-) delete mode 100644 apps/web/app/(org)/dashboard/caps/uploader.ts diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index 4d5e27a56..1623cb271 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -75,12 +75,12 @@ export const Caps = ({ const [selectedCaps, setSelectedCaps] = useState([]); const [isDraggingCap, setIsDraggingCap] = useState(false); const { - // isUploading, - // setIsUploading, - // setUploadingCapId, - // setUploadProgress, - // uploadingCapId, - // setUploadingThumbnailUrl, + isUploading, + setIsUploading, + setUploadingCapId, + setUploadProgress, + uploadingCapId, + setUploadingThumbnailUrl, } = useUploadingContext(); const anyCapSelected = selectedCaps.length > 0; @@ -267,15 +267,13 @@ export const Caps = ({ }, }); - const visibleVideos = data; // TODO: Remove this - - // const visibleVideos = useMemo( - // () => - // isUploading && uploadingCapId - // ? data.filter((video) => video.id !== uploadingCapId) - // : data, - // [data, isUploading, uploadingCapId], - // ); + const visibleVideos = useMemo( + () => + isUploading && uploadingCapId + ? data.filter((video) => video.id !== uploadingCapId) + : data, + [data, isUploading, uploadingCapId], + ); if (count === 0) return ; @@ -296,19 +294,19 @@ export const Caps = ({ New Folder { - // setIsUploading(true); - // setUploadingCapId(id); - // setUploadingThumbnailUrl(thumbnailUrl); - // setUploadProgress(0); - // }} + onStart={(id, thumbnailUrl) => { + setIsUploading(true); + setUploadingCapId(id); + setUploadingThumbnailUrl(thumbnailUrl); + setUploadProgress(0); + }} size="sm" - // onComplete={() => { - // setIsUploading(false); - // setUploadingCapId(null); - // setUploadingThumbnailUrl(undefined); - // setUploadProgress(0); - // }} + onComplete={() => { + setIsUploading(false); + setUploadingCapId(null); + setUploadingThumbnailUrl(undefined); + setUploadProgress(0); + }} />
{folders.length > 0 && ( @@ -330,9 +328,9 @@ export const Caps = ({
- {/*{isUploading && ( + {isUploading && ( - )}*/} + )} {visibleVideos.map((video) => { return ( ; - lastUpdateTime: number; -}; - -type DoUploadOptions = { - onStart?: (id: string, thumbnail?: string) => void; - onProgress?: (id: string, progress: number, uploadProgress?: number) => void; - onComplete?: (id: string) => void; -}; +import { createContext, useContext, useState } from "react"; interface UploadingContextType { isUploading: boolean; @@ -29,15 +12,6 @@ interface UploadingContextType { setUploadingThumbnailUrl: (url: string | undefined) => void; uploadProgress: number; setUploadProgress: (progress: number) => void; - - // TODO - doUpload: ( - file: File, - folderId: string | undefined, - // TODO: I wonder if we can do this better. - // This is designed to ensure the old code is compatible. - options?: DoUploadOptions, - ) => Promise; } const UploadingContext = createContext( @@ -61,103 +35,6 @@ export function UploadingProvider({ children }: { children: React.ReactNode }) { string | undefined >(undefined); const [uploadProgress, setUploadProgress] = useState(0); - const router = useRouter(); - - const [uploadState, setUploadState] = useState({ - uploaded: 0, - total: 0, - lastUpdateTime: Date.now(), - }); - - const sendProgressUpdate = async ( - videoId: string, - uploaded: number, - total: number, - ) => { - try { - const response = await fetch("/api/desktop/video/progress", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - videoId, - uploaded, - total, - updatedAt: new Date().toISOString(), - }), - }); - - if (!response.ok) - console.error("Failed to send progress update:", response.status); - } catch (err) { - console.error("Error sending progress update:", err); - } - }; - - // Prevent the user closing the tab while uploading - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (isUploading) { - e.preventDefault(); - // Chrome requires returnValue to be set - e.returnValue = ""; - return ""; - } - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, [isUploading]); - - useEffect(() => { - if (!uploadState.videoId || uploadState.uploaded === 0 || !isUploading) - return; - - // Clear any existing pending task - if (uploadState.pendingTask) clearTimeout(uploadState.pendingTask); - - const shouldSendImmediately = uploadState.uploaded >= uploadState.total; - - if (shouldSendImmediately) { - // Send completion update immediately and clear state - sendProgressUpdate( - uploadState.videoId, - uploadState.uploaded, - uploadState.total, - ); - - setUploadState((prev) => ({ - ...prev, - pendingTask: undefined, - })); - } else { - // Schedule delayed update (after 2 seconds) - const newPendingTask = setTimeout(() => { - if (uploadState.videoId) { - sendProgressUpdate( - uploadState.videoId, - uploadState.uploaded, - uploadState.total, - ); - } - }, 2000); - - setUploadState((prev) => ({ - ...prev, - pendingTask: newPendingTask, - })); - } - - return () => { - if (uploadState.pendingTask) clearTimeout(uploadState.pendingTask); - }; - }, [ - uploadState.videoId, - uploadState.uploaded, - uploadState.total, - isUploading, - ]); return ( { - setIsUploading(true); - setUploadProgress(0); - try { - const parser = await import("@remotion/media-parser"); - const webcodecs = await import("@remotion/webcodecs"); - - const metadata = await parser.parseMedia({ - src: file, - fields: { - durationInSeconds: true, - dimensions: true, - fps: true, - numberOfAudioChannels: true, - sampleRate: true, - }, - }); - - const duration = metadata.durationInSeconds - ? Math.round(metadata.durationInSeconds) - : undefined; - - const videoData = await createVideoAndGetUploadUrl({ - duration, - resolution: metadata.dimensions - ? `${metadata.dimensions.width}x${metadata.dimensions.height}` - : undefined, - videoCodec: "h264", - audioCodec: "aac", - isScreenshot: false, - isUpload: true, - folderId, - }); - - const uploadId = videoData.id; - // Initial start with thumbnail as undefined - onStart?.(uploadId); - onProgress?.(uploadId, 10); - - const fileSizeMB = file.size / (1024 * 1024); - onProgress?.(uploadId, 15); - - let optimizedBlob: Blob; - - try { - const calculateResizeOptions = () => { - if (!metadata.dimensions) return undefined; - - const { width, height } = metadata.dimensions; - const maxWidth = 1920; - const maxHeight = 1080; - - if (width <= maxWidth && height <= maxHeight) { - return undefined; - } - - const widthScale = maxWidth / width; - const heightScale = maxHeight / height; - const scale = Math.min(widthScale, heightScale); - - return { mode: "scale" as const, scale }; - }; - - const resizeOptions = calculateResizeOptions(); - - const convertResult = await webcodecs.convertMedia({ - src: file, - container: "mp4", - videoCodec: "h264", - audioCodec: "aac", - ...(resizeOptions && { resize: resizeOptions }), - onProgress: ({ overallProgress }) => { - if (overallProgress !== null) { - const progressValue = overallProgress * 100; - onProgress?.(uploadId, progressValue); - } - }, - }); - optimizedBlob = await convertResult.save(); - - if (optimizedBlob.size === 0) { - throw new Error("Conversion produced empty file"); - } - const isValidVideo = await new Promise((resolve) => { - const testVideo = document.createElement("video"); - testVideo.muted = true; - testVideo.playsInline = true; - testVideo.preload = "metadata"; - - const timeout = setTimeout(() => { - console.warn("Video validation timed out"); - URL.revokeObjectURL(testVideo.src); - resolve(false); - }, 15000); - - let metadataLoaded = false; - - const validateVideo = () => { - if (metadataLoaded) return; - metadataLoaded = true; - - const hasValidDuration = - testVideo.duration > 0 && - !isNaN(testVideo.duration) && - isFinite(testVideo.duration); - - const hasValidDimensions = - (testVideo.videoWidth > 0 && testVideo.videoHeight > 0) || - (metadata.dimensions && - metadata.dimensions.width > 0 && - metadata.dimensions.height > 0); - - if (hasValidDuration && hasValidDimensions) { - clearTimeout(timeout); - URL.revokeObjectURL(testVideo.src); - resolve(true); - } else { - console.warn( - `Invalid video properties - Duration: ${testVideo.duration}, Dimensions: ${testVideo.videoWidth}x${testVideo.videoHeight}, Original dimensions: ${metadata.dimensions?.width}x${metadata.dimensions?.height}`, - ); - clearTimeout(timeout); - URL.revokeObjectURL(testVideo.src); - resolve(false); - } - }; - - testVideo.addEventListener("loadedmetadata", validateVideo); - testVideo.addEventListener("loadeddata", validateVideo); - testVideo.addEventListener("canplay", validateVideo); - - testVideo.addEventListener("error", (e) => { - console.error("Video validation error:", e); - clearTimeout(timeout); - URL.revokeObjectURL(testVideo.src); - resolve(false); - }); - - testVideo.addEventListener("loadstart", () => {}); - - testVideo.src = URL.createObjectURL(optimizedBlob); - }); - - if (!isValidVideo) { - throw new Error("Converted video is not playable"); - } - } catch (conversionError) { - console.error("Video conversion failed:", conversionError); - toast.error( - "Failed to process video file. This format may not be supported for upload.", - ); - return; - } - - const captureThumbnail = (): Promise => { - return new Promise((resolve) => { - const video = document.createElement("video"); - video.src = URL.createObjectURL(optimizedBlob); - video.muted = true; - video.playsInline = true; - video.crossOrigin = "anonymous"; - - const cleanup = () => { - URL.revokeObjectURL(video.src); - }; - - const timeout = setTimeout(() => { - cleanup(); - console.warn( - "Thumbnail generation timed out, proceeding without thumbnail", - ); - resolve(null); - }, 10000); - - video.addEventListener("loadedmetadata", () => { - try { - const seekTime = Math.min(1, video.duration / 4); - video.currentTime = seekTime; - } catch (err) { - console.warn("Failed to seek video for thumbnail:", err); - clearTimeout(timeout); - cleanup(); - resolve(null); - } - }); - - video.addEventListener("seeked", () => { - try { - const canvas = document.createElement("canvas"); - canvas.width = video.videoWidth || 640; - canvas.height = video.videoHeight || 480; - const ctx = canvas.getContext("2d"); - if (!ctx) { - console.warn("Failed to get canvas context"); - clearTimeout(timeout); - cleanup(); - resolve(null); - return; - } - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - canvas.toBlob( - (blob) => { - clearTimeout(timeout); - cleanup(); - if (blob) { - resolve(blob); - } else { - console.warn("Failed to create thumbnail blob"); - resolve(null); - } - }, - "image/jpeg", - 0.8, - ); - } catch (err) { - console.warn("Error during thumbnail capture:", err); - clearTimeout(timeout); - cleanup(); - resolve(null); - } - }); - - video.addEventListener("error", (err) => { - console.warn("Video loading error for thumbnail:", err); - clearTimeout(timeout); - cleanup(); - resolve(null); - }); - - video.addEventListener("loadstart", () => {}); - }); - }; - - const thumbnailBlob = await captureThumbnail(); - const thumbnailUrl = thumbnailBlob - ? URL.createObjectURL(thumbnailBlob) - : undefined; - - // Pass the thumbnail URL to the parent component - onStart?.(uploadId, thumbnailUrl); - onProgress?.(uploadId, 100); - - const formData = new FormData(); - Object.entries(videoData.presignedPostData.fields).forEach( - ([key, value]) => { - formData.append(key, value as string); - }, - ); - formData.append("file", optimizedBlob); - - setUploadProgress(0); - - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("POST", videoData.presignedPostData.url); - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = (event.loaded / event.total) * 100; - setUploadProgress(percent); - onProgress?.(uploadId, 100, percent); - - setUploadState({ - videoId: uploadId, - uploaded: event.loaded, - total: event.total, - lastUpdateTime: Date.now(), - pendingTask: uploadState.pendingTask, - }); - } - }; - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - setUploadProgress(100); - onProgress?.(uploadId, 100, 100); - // sendProgressUpdate(uploadId, 100, 100); // TODO - resolve(); - } else { - reject(new Error(`Upload failed with status ${xhr.status}`)); - } - }; - xhr.onerror = () => reject(new Error("Upload failed")); - - xhr.send(formData); - }); - - if (thumbnailBlob) { - const screenshotData = await createVideoAndGetUploadUrl({ - videoId: uploadId, - isScreenshot: true, - isUpload: true, - }); - - const screenshotFormData = new FormData(); - Object.entries(screenshotData.presignedPostData.fields).forEach( - ([key, value]) => { - screenshotFormData.append(key, value as string); - }, - ); - screenshotFormData.append("file", thumbnailBlob); - - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("POST", screenshotData.presignedPostData.url); - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = (event.loaded / event.total) * 100; - // Thumbnail upload is a small additional step, keep at 100% - onProgress?.(uploadId, 100, 100); - } - }; - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - onProgress?.(uploadId, 100, 100); - resolve(); - } else { - reject( - new Error( - `Screenshot upload failed with status ${xhr.status}`, - ), - ); - } - }; - xhr.onerror = () => - reject(new Error("Screenshot upload failed")); - - xhr.send(screenshotFormData); - }); - } - - // Final progress update to ensure we're at 100% - setUploadProgress(100); - onProgress?.(uploadId, 100, 100); - onComplete?.(uploadId); - router.refresh(); - } catch (err) { - console.error("Video upload failed", err); - } finally { - setIsUploading(false); - setUploadProgress(0); - setUploadState({ - uploaded: 0, - total: 0, - lastUpdateTime: Date.now(), - }); - } - }, }} > {children} diff --git a/apps/web/app/(org)/dashboard/caps/components/index.ts b/apps/web/app/(org)/dashboard/caps/components/index.ts index 5c9758b5f..ced28c433 100644 --- a/apps/web/app/(org)/dashboard/caps/components/index.ts +++ b/apps/web/app/(org)/dashboard/caps/components/index.ts @@ -4,4 +4,4 @@ export * from "./Folder"; export * from "./NewFolderDialog"; export * from "./SelectedCapsBar"; export * from "./UploadCapButton"; -// export * from "./UploadPlaceholderCard"; +export * from "./UploadPlaceholderCard"; diff --git a/apps/web/app/(org)/dashboard/caps/uploader.ts b/apps/web/app/(org)/dashboard/caps/uploader.ts deleted file mode 100644 index 7828859b5..000000000 --- a/apps/web/app/(org)/dashboard/caps/uploader.ts +++ /dev/null @@ -1,21 +0,0 @@ -// import { mutationOptions } from "@tanstack/react-query"; - -import { MutationOptions } from "@tanstack/react-query"; - -// const todo = mutationOptions({}); - -const todo = { - mutationKey: ["todo"], - mutationFn: async (data: any) => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return "bruh"; - }, -} satisfies MutationOptions; - -export function useUploadCap() { - // TODO -} - -export function useUploadCapStatus() { - // TODO -} diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx index 184b3cbe2..1228636d2 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx @@ -12,7 +12,7 @@ import { Rpc, withRpc } from "@/lib/Rpcs"; import type { VideoData } from "../../../caps/Caps"; import { CapCard } from "../../../caps/components/CapCard/CapCard"; import { SelectedCapsBar } from "../../../caps/components/SelectedCapsBar"; -// import { UploadPlaceholderCard } from "../../../caps/components/UploadPlaceholderCard"; +import { UploadPlaceholderCard } from "../../../caps/components/UploadPlaceholderCard"; import { useUploadingContext } from "../../../caps/UploadingContext"; interface FolderVideosSectionProps { @@ -27,9 +27,7 @@ export default function FolderVideosSection({ cardType = "default", }: FolderVideosSectionProps) { const router = useRouter(); - const { - // isUploading, uploadingCapId - } = useUploadingContext(); + const { isUploading, uploadingCapId } = useUploadingContext(); const { user } = useDashboardContext(); const [selectedCaps, setSelectedCaps] = useState([]); @@ -157,14 +155,13 @@ export default function FolderVideosSection({ refetchOnMount: true, }); - const visibleVideos = initialVideos; - // const visibleVideos = useMemo( - // () => - // isUploading && uploadingCapId - // ? initialVideos.filter((video) => video.id !== uploadingCapId) - // : initialVideos, - // [initialVideos, isUploading, uploadingCapId], - // ); + const visibleVideos = useMemo( + () => + isUploading && uploadingCapId + ? initialVideos.filter((video) => video.id !== uploadingCapId) + : initialVideos, + [initialVideos, isUploading, uploadingCapId], + ); const analytics = analyticsData || {}; @@ -174,16 +171,16 @@ export default function FolderVideosSection({

Videos

- {visibleVideos.length === 0 ? ( + {visibleVideos.length === 0 && !isUploading ? (

No videos in this folder yet. Drag and drop into the folder or upload.

) : ( <> - {/*{isUploading && ( + {isUploading && ( - )}*/} + )} {visibleVideos.map((video) => ( = memo( }); const imageRef = useRef(null); - // const { uploadingCapId } = useUploadingContext(); + const { uploadingCapId } = useUploadingContext(); - // useEffect(() => { - // imageUrl.refetch(); - // }, [imageUrl.refetch, uploadingCapId]); + useEffect(() => { + imageUrl.refetch(); + }, [imageUrl.refetch, uploadingCapId]); const randomGradient = `linear-gradient(to right, ${generateRandomGrayScaleColor()}, ${generateRandomGrayScaleColor()})`; From 77024822563e93e3ac3ec3de7f3f0ccb6578fe11 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 21 Sep 2025 22:52:19 +0800 Subject: [PATCH 06/19] wip --- apps/web/app/(org)/dashboard/caps/Caps.tsx | 18 +-- .../(org)/dashboard/caps/UploadingContext.tsx | 54 +++++-- .../caps/components/UploadCapButton.tsx | 136 ++++++++---------- .../components/UploadCapButtonWithFolder.tsx | 4 +- 4 files changed, 105 insertions(+), 107 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index 1623cb271..a69a5eef9 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -76,8 +76,6 @@ export const Caps = ({ const [isDraggingCap, setIsDraggingCap] = useState(false); const { isUploading, - setIsUploading, - setUploadingCapId, setUploadProgress, uploadingCapId, setUploadingThumbnailUrl, @@ -293,21 +291,7 @@ export const Caps = ({ New Folder - { - setIsUploading(true); - setUploadingCapId(id); - setUploadingThumbnailUrl(thumbnailUrl); - setUploadProgress(0); - }} - size="sm" - onComplete={() => { - setIsUploading(false); - setUploadingCapId(null); - setUploadingThumbnailUrl(undefined); - setUploadProgress(0); - }} - /> +
{folders.length > 0 && ( <> diff --git a/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx b/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx index f52eb9405..86ce6a381 100644 --- a/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx +++ b/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx @@ -1,19 +1,40 @@ "use client"; import type React from "react"; -import { createContext, useContext, useState } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; interface UploadingContextType { isUploading: boolean; - setIsUploading: (value: boolean) => void; + // setIsUploading: (value: boolean) => void; uploadingCapId: string | null; - setUploadingCapId: (id: string | null) => void; + // setUploadingCapId: (id: string | null) => void; uploadingThumbnailUrl: string | undefined; setUploadingThumbnailUrl: (url: string | undefined) => void; uploadProgress: number; setUploadProgress: (progress: number) => void; + + state: UploadState | undefined; + setState: (state: UploadState | undefined) => void; } +type UploadState = + | { + status: "parsing"; + } + | { + status: "creating"; + } + | { + status: "converting"; + capId: string; + progress: number; + } + | { + status: "uploading"; + capId: string; + progress: number; + }; + const UploadingContext = createContext( undefined, ); @@ -29,20 +50,35 @@ export function useUploadingContext() { } export function UploadingProvider({ children }: { children: React.ReactNode }) { - const [isUploading, setIsUploading] = useState(false); - const [uploadingCapId, setUploadingCapId] = useState(null); + const [state, setState] = useState(); + const [uploadingThumbnailUrl, setUploadingThumbnailUrl] = useState< string | undefined >(undefined); const [uploadProgress, setUploadProgress] = useState(0); + // Prevent the user closing the tab while uploading + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (state?.status) { + e.preventDefault(); + // Chrome requires returnValue to be set + e.returnValue = ""; + return ""; + } + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [state]); + return ( void; - onProgress?: (id: string, progress: number, uploadProgress?: number) => void; - onComplete?: (id: string) => void; size?: "sm" | "lg" | "md"; grey?: boolean; folderId?: string; }) => { const { user } = useDashboardContext(); const inputRef = useRef(null); - const { isUploading, setIsUploading, setUploadProgress } = - useUploadingContext(); + const { state, setState } = useUploadingContext(); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const router = useRouter(); @@ -64,12 +57,11 @@ export const UploadCapButton = ({ const file = e.target.files?.[0]; if (!file || !user) return; - setIsUploading(true); - setUploadProgress(0); - try { - const parser = await import("@remotion/media-parser"); - const webcodecs = await import("@remotion/webcodecs"); + const parser = await import("@remotion/media-parser"); + const webcodecs = await import("@remotion/webcodecs"); + setState({ status: "parsing" }); + try { const metadata = await parser.parseMedia({ src: file, fields: { @@ -102,7 +94,6 @@ export const UploadCapButton = ({ onStart?.(uploadId); onProgress?.(uploadId, 10); - const fileSizeMB = file.size / (1024 * 1024); onProgress?.(uploadId, 15); let optimizedBlob: Blob; @@ -396,8 +387,7 @@ export const UploadCapButton = ({ } catch (err) { console.error("Video upload failed", err); } finally { - setIsUploading(false); - setUploadProgress(0); + setState(undefined); setUploadState({ uploaded: 0, total: 0, @@ -433,69 +423,57 @@ export const UploadCapButton = ({ } }; - // Prevent the user closing the tab while uploading - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (isUploading) { - e.preventDefault(); - // Chrome requires returnValue to be set - e.returnValue = ""; - return ""; - } - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, [isUploading]); - - useEffect(() => { - if (!uploadState.videoId || uploadState.uploaded === 0 || !isUploading) - return; - - // Clear any existing pending task - if (uploadState.pendingTask) clearTimeout(uploadState.pendingTask); - - const shouldSendImmediately = uploadState.uploaded >= uploadState.total; - - if (shouldSendImmediately) { - // Send completion update immediately and clear state - sendProgressUpdate( - uploadState.videoId, - uploadState.uploaded, - uploadState.total, - ); - - setUploadState((prev) => ({ - ...prev, - pendingTask: undefined, - })); - } else { - // Schedule delayed update (after 2 seconds) - const newPendingTask = setTimeout(() => { - if (uploadState.videoId) { - sendProgressUpdate( - uploadState.videoId, - uploadState.uploaded, - uploadState.total, - ); - } - }, 2000); - - setUploadState((prev) => ({ - ...prev, - pendingTask: newPendingTask, - })); - } - - return () => { - if (uploadState.pendingTask) clearTimeout(uploadState.pendingTask); - }; - }, [ - uploadState.videoId, - uploadState.uploaded, - uploadState.total, - isUploading, - ]); + // TODO: Bring this back + // useEffect(() => { + // if (!uploadState.videoId || uploadState.uploaded === 0 || !isUploading) + // return; + + // // Clear any existing pending task + // if (uploadState.pendingTask) clearTimeout(uploadState.pendingTask); + + // const shouldSendImmediately = uploadState.uploaded >= uploadState.total; + + // if (shouldSendImmediately) { + // // Send completion update immediately and clear state + // sendProgressUpdate( + // uploadState.videoId, + // uploadState.uploaded, + // uploadState.total, + // ); + + // setUploadState((prev) => ({ + // ...prev, + // pendingTask: undefined, + // })); + // } else { + // // Schedule delayed update (after 2 seconds) + // const newPendingTask = setTimeout(() => { + // if (uploadState.videoId) { + // sendProgressUpdate( + // uploadState.videoId, + // uploadState.uploaded, + // uploadState.total, + // ); + // } + // }, 2000); + + // setUploadState((prev) => ({ + // ...prev, + // pendingTask: newPendingTask, + // })); + // } + + // return () => { + // if (uploadState.pendingTask) clearTimeout(uploadState.pendingTask); + // }; + // }, [ + // uploadState.videoId, + // uploadState.uploaded, + // uploadState.total, + // isUploading, + // ]); + + const isUploading = !!state; // TODO return ( <> diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/UploadCapButtonWithFolder.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/UploadCapButtonWithFolder.tsx index 242793660..32acd190f 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/UploadCapButtonWithFolder.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/UploadCapButtonWithFolder.tsx @@ -6,8 +6,8 @@ import { useUploadingContext } from "../../../caps/UploadingContext"; export function UploadCapButtonWithFolder({ folderId }: { folderId: string }) { const router = useRouter(); const { - setIsUploading, - setUploadingCapId, + // setIsUploading, + // setUploadingCapId, setUploadingThumbnailUrl, setUploadProgress, } = useUploadingContext(); From c626251238a0f711aed4238911892066a091b422 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 21 Sep 2025 23:13:42 +0800 Subject: [PATCH 07/19] better progress tracking --- apps/web/app/(org)/dashboard/caps/Caps.tsx | 10 ++-- .../(org)/dashboard/caps/UploadingContext.tsx | 38 ++++++--------- .../caps/components/UploadCapButton.tsx | 40 ++++++++-------- .../caps/components/UploadPlaceholderCard.tsx | 46 +++++++++++++------ .../components/UploadCapButtonWithFolder.tsx | 35 -------------- .../app/(org)/dashboard/folder/[id]/page.tsx | 4 +- packages/utils/src/helpers.ts | 5 -- 7 files changed, 75 insertions(+), 103 deletions(-) delete mode 100644 apps/web/app/(org)/dashboard/folder/[id]/components/UploadCapButtonWithFolder.tsx diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index a69a5eef9..f5bcbf5e8 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -74,12 +74,7 @@ export const Caps = ({ const previousCountRef = useRef(0); const [selectedCaps, setSelectedCaps] = useState([]); const [isDraggingCap, setIsDraggingCap] = useState(false); - const { - isUploading, - setUploadProgress, - uploadingCapId, - setUploadingThumbnailUrl, - } = useUploadingContext(); + const { state } = useUploadingContext(); const anyCapSelected = selectedCaps.length > 0; @@ -265,6 +260,9 @@ export const Caps = ({ }, }); + const isUploading = state !== undefined; + const uploadingCapId = state && "capId" in state ? state.capId : undefined; + const visibleVideos = useMemo( () => isUploading && uploadingCapId diff --git a/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx b/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx index 86ce6a381..989723cf1 100644 --- a/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx +++ b/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx @@ -4,20 +4,15 @@ import type React from "react"; import { createContext, useContext, useEffect, useState } from "react"; interface UploadingContextType { - isUploading: boolean; - // setIsUploading: (value: boolean) => void; - uploadingCapId: string | null; - // setUploadingCapId: (id: string | null) => void; - uploadingThumbnailUrl: string | undefined; - setUploadingThumbnailUrl: (url: string | undefined) => void; - uploadProgress: number; - setUploadProgress: (progress: number) => void; - + // TODO: Rename these state: UploadState | undefined; setState: (state: UploadState | undefined) => void; + + isUploading: boolean; + uploadingCapId: string | null; } -type UploadState = +export type UploadState = | { status: "parsing"; } @@ -30,7 +25,12 @@ type UploadState = progress: number; } | { - status: "uploading"; + status: "uploadingThumbnail"; + capId: string; + progress: number; + } + | { + status: "uploadingVideo"; capId: string; progress: number; }; @@ -41,22 +41,16 @@ const UploadingContext = createContext( export function useUploadingContext() { const context = useContext(UploadingContext); - if (!context) { + if (!context) throw new Error( "useUploadingContext must be used within an UploadingProvider", ); - } return context; } export function UploadingProvider({ children }: { children: React.ReactNode }) { const [state, setState] = useState(); - const [uploadingThumbnailUrl, setUploadingThumbnailUrl] = useState< - string | undefined - >(undefined); - const [uploadProgress, setUploadProgress] = useState(0); - // Prevent the user closing the tab while uploading useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { @@ -75,14 +69,10 @@ export function UploadingProvider({ children }: { children: React.ReactNode }) { return ( {children} diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx index aa53fba0c..a723afe5a 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx @@ -5,7 +5,7 @@ import { userIsPro } from "@cap/utils"; import { faUpload } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; import { createVideoAndGetUploadUrl } from "@/actions/video/upload"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; @@ -60,8 +60,8 @@ export const UploadCapButton = ({ const parser = await import("@remotion/media-parser"); const webcodecs = await import("@remotion/webcodecs"); - setState({ status: "parsing" }); try { + setState({ status: "parsing" }); const metadata = await parser.parseMedia({ src: file, fields: { @@ -77,6 +77,8 @@ export const UploadCapButton = ({ ? Math.round(metadata.durationInSeconds) : undefined; + setState({ status: "creating" }); + const videoData = await createVideoAndGetUploadUrl({ duration, resolution: metadata.dimensions @@ -90,11 +92,8 @@ export const UploadCapButton = ({ }); const uploadId = videoData.id; - // Initial start with thumbnail as undefined - onStart?.(uploadId); - onProgress?.(uploadId, 10); - onProgress?.(uploadId, 15); + setState({ status: "converting", capId: uploadId, progress: 0 }); let optimizedBlob: Blob; @@ -128,7 +127,11 @@ export const UploadCapButton = ({ onProgress: ({ overallProgress }) => { if (overallProgress !== null) { const progressValue = overallProgress * 100; - onProgress?.(uploadId, progressValue); + setState({ + status: "converting", + capId: uploadId, + progress: progressValue, + }); } }, }); @@ -291,10 +294,6 @@ export const UploadCapButton = ({ ? URL.createObjectURL(thumbnailBlob) : undefined; - // Pass the thumbnail URL to the parent component - onStart?.(uploadId, thumbnailUrl); - onProgress?.(uploadId, 100); - const formData = new FormData(); Object.entries(videoData.presignedPostData.fields).forEach( ([key, value]) => { @@ -303,8 +302,6 @@ export const UploadCapButton = ({ ); formData.append("file", optimizedBlob); - setUploadProgress(0); - await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", videoData.presignedPostData.url); @@ -312,8 +309,11 @@ export const UploadCapButton = ({ xhr.upload.onprogress = (event) => { if (event.lengthComputable) { const percent = (event.loaded / event.total) * 100; - setUploadProgress(percent); - onProgress?.(uploadId, 100, percent); + setState({ + status: "uploadingThumbnail", + capId: uploadId, + progress: percent, + }); setUploadState({ videoId: uploadId, @@ -361,7 +361,11 @@ export const UploadCapButton = ({ if (event.lengthComputable) { const percent = (event.loaded / event.total) * 100; const thumbnailProgress = 90 + percent * 0.1; - onProgress?.(uploadId, 100, thumbnailProgress); + setState({ + status: "uploadingThumbnail", + capId: uploadId, + progress: thumbnailProgress, + }); } }; @@ -381,8 +385,8 @@ export const UploadCapButton = ({ } else { } - onProgress?.(uploadId, 100, 100); - onComplete?.(uploadId); + setState(undefined); + router.refresh(); } catch (err) { console.error("Video upload failed", err); diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx index 18806ea85..89c8a0463 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx @@ -1,26 +1,28 @@ "use client"; import { LogoSpinner } from "@cap/ui"; -import { - calculateStrokeDashoffset, - getProgressCircleConfig, - getUploadStatus, -} from "@cap/utils"; -import { useUploadingContext } from "../UploadingContext"; +import { calculateStrokeDashoffset, getProgressCircleConfig } from "@cap/utils"; +import { UploadState, useUploadingContext } from "../UploadingContext"; + +const { circumference } = getProgressCircleConfig(); export const UploadPlaceholderCard = () => { - const { uploadingThumbnailUrl, uploadProgress } = useUploadingContext(); - const { circumference } = getProgressCircleConfig(); - const status = getUploadStatus(uploadProgress); + const { state } = useUploadingContext(); const strokeDashoffset = calculateStrokeDashoffset( - uploadProgress, + state && + (state.status === "converting" || + state.status === "uploadingThumbnail" || + state.status === "uploadingVideo") + ? state.progress + : 0, circumference, ); + if (!state) return null; return (
- {uploadingThumbnailUrl ? ( + {/*{uploadingThumbnailUrl ? ( Uploading thumbnail {
- )} + )}*/}
- {status} + + {getFriendlyStatus(state.status)} + {
); }; + +function getFriendlyStatus(status: UploadState["status"]) { + switch (status) { + case "parsing": + return "Parsing"; + case "creating": + return "Creating"; + case "converting": + return "Converting"; + case "uploadingThumbnail": + case "uploadingVideo": + return "Uploading"; + default: + return "Processing..."; + } +} diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/UploadCapButtonWithFolder.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/UploadCapButtonWithFolder.tsx deleted file mode 100644 index 32acd190f..000000000 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/UploadCapButtonWithFolder.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { UploadCapButton } from "../../../caps/components/UploadCapButton"; -import { useUploadingContext } from "../../../caps/UploadingContext"; -export function UploadCapButtonWithFolder({ folderId }: { folderId: string }) { - const router = useRouter(); - const { - // setIsUploading, - // setUploadingCapId, - setUploadingThumbnailUrl, - setUploadProgress, - } = useUploadingContext(); - - return ( - { - setIsUploading(true); - setUploadingCapId(id); - setUploadingThumbnailUrl(thumbnail); - setUploadProgress(0); - }} - onComplete={(id) => { - // Reset all uploading state - setIsUploading(false); - setUploadingCapId(null); - setUploadingThumbnailUrl(undefined); - setUploadProgress(0); - router.refresh(); - }} - folderId={folderId} - size="sm" - /> - ); -} diff --git a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx index 0cd730250..e0973f051 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx @@ -12,7 +12,7 @@ import { NewSubfolderButton, } from "./components"; import FolderVideosSection from "./components/FolderVideosSection"; -import { UploadCapButtonWithFolder } from "./components/UploadCapButtonWithFolder"; +import { UploadCapButton } from "../../caps/components"; const FolderPage = async ({ params }: { params: { id: Folder.FolderId } }) => { const [childFolders, breadcrumb, videosData] = await Promise.all([ @@ -25,7 +25,7 @@ const FolderPage = async ({ params }: { params: { id: Folder.FolderId } }) => {
- +
diff --git a/packages/utils/src/helpers.ts b/packages/utils/src/helpers.ts index 7ee4ab6a2..4ea505db7 100644 --- a/packages/utils/src/helpers.ts +++ b/packages/utils/src/helpers.ts @@ -74,11 +74,6 @@ export const calculateStrokeDashoffset = ( return circumference - (progress / 100) * circumference; }; -export const getUploadStatus = (uploadProgress?: number) => { - if (uploadProgress !== undefined) return "Uploading"; - return "Processing"; -}; - export const getDisplayProgress = ( uploadProgress?: number, processingProgress: number = 0, From 6a5af82198cdad632076983f1891ca5e80355061 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 21 Sep 2025 23:20:59 +0800 Subject: [PATCH 08/19] fix thumbnail on placeholder --- apps/web/app/(org)/dashboard/caps/UploadingContext.tsx | 1 + .../app/(org)/dashboard/caps/components/UploadCapButton.tsx | 3 ++- .../dashboard/caps/components/UploadPlaceholderCard.tsx | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx b/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx index 989723cf1..e53446f5a 100644 --- a/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx +++ b/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx @@ -33,6 +33,7 @@ export type UploadState = status: "uploadingVideo"; capId: string; progress: number; + thumbnailUrl: string | undefined; }; const UploadingContext = createContext( diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx index a723afe5a..cae177ddb 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx @@ -310,9 +310,10 @@ export const UploadCapButton = ({ if (event.lengthComputable) { const percent = (event.loaded / event.total) * 100; setState({ - status: "uploadingThumbnail", + status: "uploadingVideo", capId: uploadId, progress: percent, + thumbnailUrl: thumbnailUrl, }); setUploadState({ diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx index 89c8a0463..9a510cf14 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx @@ -22,9 +22,9 @@ export const UploadPlaceholderCard = () => { return (
- {/*{uploadingThumbnailUrl ? ( + {state.status === "uploading" ? ( Uploading thumbnail @@ -32,7 +32,7 @@ export const UploadPlaceholderCard = () => {
- )}*/} + )}
From 73b4c85573dbc68922c49b67a8a731c298819277 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 21 Sep 2025 23:38:55 +0800 Subject: [PATCH 09/19] wip --- .../(org)/dashboard/caps/components/UploadPlaceholderCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx index 9a510cf14..1d76dbdbb 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx @@ -22,7 +22,7 @@ export const UploadPlaceholderCard = () => { return (
- {state.status === "uploading" ? ( + {state.status === "uploadingVideo" ? ( Uploading thumbnail Date: Sun, 21 Sep 2025 23:41:40 +0800 Subject: [PATCH 10/19] cleanup upload progress --- .../dashboard/caps/components/UploadCapButton.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx index cae177ddb..5e4f6e4d5 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx @@ -78,7 +78,6 @@ export const UploadCapButton = ({ : undefined; setState({ status: "creating" }); - const videoData = await createVideoAndGetUploadUrl({ duration, resolution: metadata.dimensions @@ -302,6 +301,12 @@ export const UploadCapButton = ({ ); formData.append("file", optimizedBlob); + setState({ + status: "uploadingVideo", + capId: uploadId, + progress: 0, + thumbnailUrl, + }); await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", videoData.presignedPostData.url); @@ -313,7 +318,7 @@ export const UploadCapButton = ({ status: "uploadingVideo", capId: uploadId, progress: percent, - thumbnailUrl: thumbnailUrl, + thumbnailUrl, }); setUploadState({ @@ -354,6 +359,12 @@ export const UploadCapButton = ({ ); screenshotFormData.append("file", thumbnailBlob); + setState({ + status: "uploadingThumbnail", + capId: uploadId, + progress: 0, + thumbnailUrl, + }); await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", screenshotData.presignedPostData.url); From f7bb6bc1e098e7c03784b5a9557f5052a1b5a939 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Sun, 21 Sep 2025 23:50:13 +0800 Subject: [PATCH 11/19] improvements --- apps/web/app/(org)/dashboard/caps/Caps.tsx | 11 ++++---- .../(org)/dashboard/caps/UploadingContext.tsx | 18 +++++-------- .../caps/components/UploadCapButton.tsx | 25 +++++++++---------- .../caps/components/UploadPlaceholderCard.tsx | 24 +++++++++--------- .../[id]/components/FolderVideosSection.tsx | 6 ++++- apps/web/components/VideoThumbnail.tsx | 4 ++- 6 files changed, 43 insertions(+), 45 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index f5bcbf5e8..b294f513a 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -74,7 +74,7 @@ export const Caps = ({ const previousCountRef = useRef(0); const [selectedCaps, setSelectedCaps] = useState([]); const [isDraggingCap, setIsDraggingCap] = useState(false); - const { state } = useUploadingContext(); + const { uploadStatus } = useUploadingContext(); const anyCapSelected = selectedCaps.length > 0; @@ -255,13 +255,12 @@ export const Caps = ({ toast.success("Cap deleted successfully"); router.refresh(); }, - onError: () => { - toast.error("Failed to delete cap"); - }, + onError: () => toast.error("Failed to delete cap"), }); - const isUploading = state !== undefined; - const uploadingCapId = state && "capId" in state ? state.capId : undefined; + const isUploading = uploadStatus !== undefined; + const uploadingCapId = + uploadStatus && "capId" in uploadStatus ? uploadStatus.capId : undefined; const visibleVideos = useMemo( () => diff --git a/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx b/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx index e53446f5a..18def19ad 100644 --- a/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx +++ b/apps/web/app/(org)/dashboard/caps/UploadingContext.tsx @@ -4,15 +4,11 @@ import type React from "react"; import { createContext, useContext, useEffect, useState } from "react"; interface UploadingContextType { - // TODO: Rename these - state: UploadState | undefined; - setState: (state: UploadState | undefined) => void; - - isUploading: boolean; - uploadingCapId: string | null; + uploadStatus: UploadStatus | undefined; + setUploadStatus: (state: UploadStatus | undefined) => void; } -export type UploadState = +export type UploadStatus = | { status: "parsing"; } @@ -50,7 +46,7 @@ export function useUploadingContext() { } export function UploadingProvider({ children }: { children: React.ReactNode }) { - const [state, setState] = useState(); + const [state, setState] = useState(); // Prevent the user closing the tab while uploading useEffect(() => { @@ -70,10 +66,8 @@ export function UploadingProvider({ children }: { children: React.ReactNode }) { return ( {children} diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx index 5e4f6e4d5..36e80a8cb 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx @@ -30,7 +30,7 @@ export const UploadCapButton = ({ }) => { const { user } = useDashboardContext(); const inputRef = useRef(null); - const { state, setState } = useUploadingContext(); + const { uploadStatus, setUploadStatus } = useUploadingContext(); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const router = useRouter(); @@ -61,7 +61,7 @@ export const UploadCapButton = ({ const webcodecs = await import("@remotion/webcodecs"); try { - setState({ status: "parsing" }); + setUploadStatus({ status: "parsing" }); const metadata = await parser.parseMedia({ src: file, fields: { @@ -77,7 +77,7 @@ export const UploadCapButton = ({ ? Math.round(metadata.durationInSeconds) : undefined; - setState({ status: "creating" }); + setUploadStatus({ status: "creating" }); const videoData = await createVideoAndGetUploadUrl({ duration, resolution: metadata.dimensions @@ -92,7 +92,7 @@ export const UploadCapButton = ({ const uploadId = videoData.id; - setState({ status: "converting", capId: uploadId, progress: 0 }); + setUploadStatus({ status: "converting", capId: uploadId, progress: 0 }); let optimizedBlob: Blob; @@ -126,7 +126,7 @@ export const UploadCapButton = ({ onProgress: ({ overallProgress }) => { if (overallProgress !== null) { const progressValue = overallProgress * 100; - setState({ + setUploadStatus({ status: "converting", capId: uploadId, progress: progressValue, @@ -301,7 +301,7 @@ export const UploadCapButton = ({ ); formData.append("file", optimizedBlob); - setState({ + setUploadStatus({ status: "uploadingVideo", capId: uploadId, progress: 0, @@ -314,7 +314,7 @@ export const UploadCapButton = ({ xhr.upload.onprogress = (event) => { if (event.lengthComputable) { const percent = (event.loaded / event.total) * 100; - setState({ + setUploadStatus({ status: "uploadingVideo", capId: uploadId, progress: percent, @@ -359,11 +359,10 @@ export const UploadCapButton = ({ ); screenshotFormData.append("file", thumbnailBlob); - setState({ + setUploadStatus({ status: "uploadingThumbnail", capId: uploadId, progress: 0, - thumbnailUrl, }); await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); @@ -373,7 +372,7 @@ export const UploadCapButton = ({ if (event.lengthComputable) { const percent = (event.loaded / event.total) * 100; const thumbnailProgress = 90 + percent * 0.1; - setState({ + setUploadStatus({ status: "uploadingThumbnail", capId: uploadId, progress: thumbnailProgress, @@ -397,13 +396,13 @@ export const UploadCapButton = ({ } else { } - setState(undefined); + setUploadStatus(undefined); router.refresh(); } catch (err) { console.error("Video upload failed", err); } finally { - setState(undefined); + setUploadStatus(undefined); setUploadState({ uploaded: 0, total: 0, @@ -489,7 +488,7 @@ export const UploadCapButton = ({ // isUploading, // ]); - const isUploading = !!state; // TODO + const isUploading = !!uploadStatus; return ( <> diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx index 1d76dbdbb..dd6966490 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadPlaceholderCard.tsx @@ -2,29 +2,29 @@ import { LogoSpinner } from "@cap/ui"; import { calculateStrokeDashoffset, getProgressCircleConfig } from "@cap/utils"; -import { UploadState, useUploadingContext } from "../UploadingContext"; +import { type UploadStatus, useUploadingContext } from "../UploadingContext"; const { circumference } = getProgressCircleConfig(); export const UploadPlaceholderCard = () => { - const { state } = useUploadingContext(); + const { uploadStatus } = useUploadingContext(); const strokeDashoffset = calculateStrokeDashoffset( - state && - (state.status === "converting" || - state.status === "uploadingThumbnail" || - state.status === "uploadingVideo") - ? state.progress + uploadStatus && + (uploadStatus.status === "converting" || + uploadStatus.status === "uploadingThumbnail" || + uploadStatus.status === "uploadingVideo") + ? uploadStatus.progress : 0, circumference, ); - if (!state) return null; + if (!uploadStatus) return null; return (
- {state.status === "uploadingVideo" ? ( + {uploadStatus.status === "uploadingVideo" ? ( Uploading thumbnail @@ -38,7 +38,7 @@ export const UploadPlaceholderCard = () => {
- {getFriendlyStatus(state.status)} + {getFriendlyStatus(uploadStatus.status)} { ); }; -function getFriendlyStatus(status: UploadState["status"]) { +function getFriendlyStatus(status: UploadStatus["status"]) { switch (status) { case "parsing": return "Parsing"; diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx index 1228636d2..90563e357 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/FolderVideosSection.tsx @@ -27,9 +27,13 @@ export default function FolderVideosSection({ cardType = "default", }: FolderVideosSectionProps) { const router = useRouter(); - const { isUploading, uploadingCapId } = useUploadingContext(); + const { uploadStatus } = useUploadingContext(); const { user } = useDashboardContext(); + const isUploading = uploadStatus !== undefined; + const uploadingCapId = + uploadStatus && "capId" in uploadStatus ? uploadStatus.capId : null; + const [selectedCaps, setSelectedCaps] = useState([]); const previousCountRef = useRef(0); diff --git a/apps/web/components/VideoThumbnail.tsx b/apps/web/components/VideoThumbnail.tsx index 6a8e3fb24..664404d90 100644 --- a/apps/web/components/VideoThumbnail.tsx +++ b/apps/web/components/VideoThumbnail.tsx @@ -70,8 +70,10 @@ export const VideoThumbnail: React.FC = memo( }); const imageRef = useRef(null); - const { uploadingCapId } = useUploadingContext(); + const { uploadStatus } = useUploadingContext(); + const uploadingCapId = + uploadStatus && "capId" in uploadStatus ? uploadStatus.capId : null; useEffect(() => { imageUrl.refetch(); }, [imageUrl.refetch, uploadingCapId]); From 3a873c266427109aaeb571ce8511493317f017f3 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 22 Sep 2025 00:03:05 +0800 Subject: [PATCH 12/19] disable progress by default --- .../app/(org)/dashboard/caps/components/CapCard/CapCard.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index 2a2078469..a2051f009 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -81,6 +81,10 @@ export interface CapCardProps extends PropsWithChildren { onDragEnd?: () => void; } +// localStorage.setItem("betaUploadProgress", "true"); +const enableBetaUploadProgress = + localStorage.getItem("betaUploadProgress") === "true"; + export const CapCard = ({ cap, analytics, @@ -166,7 +170,7 @@ export const CapCard = ({ const uploadProgress = useUploadProgress( cap.id, - cap.hasActiveUpload || false, + enableBetaUploadProgress && (cap.hasActiveUpload || false,) ); // Helper function to create a drag preview element From 3d012e2e39d9c799b0186007e3c922ca690388db Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 22 Sep 2025 00:04:34 +0800 Subject: [PATCH 13/19] improvements --- .../caps/components/UploadCapButton.tsx | 195 +++++++++--------- 1 file changed, 93 insertions(+), 102 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx index 36e80a8cb..6371ca685 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx @@ -12,14 +12,6 @@ import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; import { useUploadingContext } from "@/app/(org)/dashboard/caps/UploadingContext"; import { UpgradeModal } from "@/components/UpgradeModal"; -type UploadState = { - videoId?: string; - uploaded: number; - total: number; - pendingTask?: ReturnType; - lastUpdateTime: number; -}; - export const UploadCapButton = ({ size = "md", folderId, @@ -34,12 +26,6 @@ export const UploadCapButton = ({ const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const router = useRouter(); - const [uploadState, setUploadState] = useState({ - uploaded: 0, - total: 0, - lastUpdateTime: Date.now(), - }); - const handleClick = () => { if (!user) return; @@ -307,42 +293,105 @@ export const UploadCapButton = ({ progress: 0, thumbnailUrl, }); - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("POST", videoData.presignedPostData.url); - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = (event.loaded / event.total) * 100; - setUploadStatus({ - status: "uploadingVideo", - capId: uploadId, - progress: percent, - thumbnailUrl, - }); - - setUploadState({ - videoId: uploadId, - uploaded: event.loaded, - total: event.total, - lastUpdateTime: Date.now(), - pendingTask: uploadState.pendingTask, - }); - } + + // Create progress tracking state outside React + const createProgressTracker = () => { + let uploadState = { + videoId: uploadId, + uploaded: 0, + total: 0, + pendingTask: undefined as ReturnType | undefined, + lastUpdateTime: Date.now(), }; - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - sendProgressUpdate(uploadId, uploadState.total, uploadState.total); - resolve(); + const scheduleProgressUpdate = (uploaded: number, total: number) => { + uploadState.uploaded = uploaded; + uploadState.total = total; + uploadState.lastUpdateTime = Date.now(); + + // Clear any existing pending task + if (uploadState.pendingTask) { + clearTimeout(uploadState.pendingTask); + uploadState.pendingTask = undefined; + } + + const shouldSendImmediately = uploaded >= total; + + if (shouldSendImmediately) { + // Don't send completion update immediately - let xhr.onload handle it + // to avoid double progress updates + return; } else { - reject(new Error(`Upload failed with status ${xhr.status}`)); + // Schedule delayed update (after 2 seconds) + uploadState.pendingTask = setTimeout(() => { + if (uploadState.videoId) { + sendProgressUpdate( + uploadState.videoId, + uploadState.uploaded, + uploadState.total, + ); + } + uploadState.pendingTask = undefined; + }, 2000); } }; - xhr.onerror = () => reject(new Error("Upload failed")); - xhr.send(formData); - }); + const cleanup = () => { + if (uploadState.pendingTask) { + clearTimeout(uploadState.pendingTask); + uploadState.pendingTask = undefined; + } + }; + + const getTotal = () => uploadState.total; + + return { scheduleProgressUpdate, cleanup, getTotal }; + }; + + const progressTracker = createProgressTracker(); + + try { + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", videoData.presignedPostData.url); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percent = (event.loaded / event.total) * 100; + setUploadStatus({ + status: "uploadingVideo", + capId: uploadId, + progress: percent, + thumbnailUrl, + }); + + progressTracker.scheduleProgressUpdate(event.loaded, event.total); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + progressTracker.cleanup(); + // Guarantee final 100% progress update + const total = progressTracker.getTotal() || 1; + sendProgressUpdate(uploadId, total, total); + resolve(); + } else { + progressTracker.cleanup(); + reject(new Error(`Upload failed with status ${xhr.status}`)); + } + }; + xhr.onerror = () => { + progressTracker.cleanup(); + reject(new Error("Upload failed")); + }; + + xhr.send(formData); + }); + } catch (uploadError) { + progressTracker.cleanup(); + throw uploadError; + } if (thumbnailBlob) { const screenshotData = await createVideoAndGetUploadUrl({ @@ -393,21 +442,13 @@ export const UploadCapButton = ({ xhr.send(screenshotFormData); }); - } else { } - setUploadStatus(undefined); - router.refresh(); } catch (err) { console.error("Video upload failed", err); } finally { setUploadStatus(undefined); - setUploadState({ - uploaded: 0, - total: 0, - lastUpdateTime: Date.now(), - }); if (inputRef.current) inputRef.current.value = ""; } }; @@ -438,56 +479,6 @@ export const UploadCapButton = ({ } }; - // TODO: Bring this back - // useEffect(() => { - // if (!uploadState.videoId || uploadState.uploaded === 0 || !isUploading) - // return; - - // // Clear any existing pending task - // if (uploadState.pendingTask) clearTimeout(uploadState.pendingTask); - - // const shouldSendImmediately = uploadState.uploaded >= uploadState.total; - - // if (shouldSendImmediately) { - // // Send completion update immediately and clear state - // sendProgressUpdate( - // uploadState.videoId, - // uploadState.uploaded, - // uploadState.total, - // ); - - // setUploadState((prev) => ({ - // ...prev, - // pendingTask: undefined, - // })); - // } else { - // // Schedule delayed update (after 2 seconds) - // const newPendingTask = setTimeout(() => { - // if (uploadState.videoId) { - // sendProgressUpdate( - // uploadState.videoId, - // uploadState.uploaded, - // uploadState.total, - // ); - // } - // }, 2000); - - // setUploadState((prev) => ({ - // ...prev, - // pendingTask: newPendingTask, - // })); - // } - - // return () => { - // if (uploadState.pendingTask) clearTimeout(uploadState.pendingTask); - // }; - // }, [ - // uploadState.videoId, - // uploadState.uploaded, - // uploadState.total, - // isUploading, - // ]); - const isUploading = !!uploadStatus; return ( From 2be4c86f95c1a0951185a0d3d85cf550dae0ab12 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 22 Sep 2025 00:11:07 +0800 Subject: [PATCH 14/19] fixes --- .../caps/components/CapCard/CapCard.tsx | 6 ++-- apps/web/app/api/desktop/[...route]/video.ts | 33 ++++++++----------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index a2051f009..b6cf2a41e 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -83,7 +83,9 @@ export interface CapCardProps extends PropsWithChildren { // localStorage.setItem("betaUploadProgress", "true"); const enableBetaUploadProgress = - localStorage.getItem("betaUploadProgress") === "true"; + "localStorage" in globalThis + ? localStorage.getItem("betaUploadProgress") === "true" + : false; export const CapCard = ({ cap, @@ -170,7 +172,7 @@ export const CapCard = ({ const uploadProgress = useUploadProgress( cap.id, - enableBetaUploadProgress && (cap.hasActiveUpload || false,) + enableBetaUploadProgress && (cap.hasActiveUpload || false), ); // Helper function to create a drag preview element diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index b3cd427f0..725a2282e 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -127,7 +127,7 @@ app.get( const xCapVersion = c.req.header("X-Cap-Desktop-Version"); const clientSupportsUploadProgress = xCapVersion - ? isGreaterThanSemver(xCapVersion, 0, 3, 68) + ? isAtLeastSemver(xCapVersion, 0, 3, 68) : false; if (clientSupportsUploadProgress) @@ -318,31 +318,26 @@ app.post( }, ); -function isGreaterThanSemver( +function isAtLeastSemver( versionString: string, major: number, minor: number, patch: number, ): boolean { - // Parse version string, remove 'v' prefix if present const match = versionString .replace(/^v/, "") .match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?/); - - if (!match) { - throw new Error(`Invalid semver version: ${versionString}`); - } - + if (!match) return false; const [, vMajor, vMinor, vPatch, prerelease] = match; - const parsedMajor = vMajor ? parseInt(vMajor, 10) : 0; - const parsedMinor = vMinor ? parseInt(vMinor, 10) : 0; - const parsedPatch = vPatch ? parseInt(vPatch, 10) : 0; - - // Compare major.minor.patch - if (parsedMajor !== major) return parsedMajor > major; - if (parsedMinor !== minor) return parsedMinor > minor; - if (parsedPatch !== patch) return parsedPatch > patch; - - // If versions are equal, prerelease versions have lower precedence - return !prerelease; // true if no prerelease (1.0.0 > 1.0.0-alpha), false if prerelease + const M = vMajor ? parseInt(vMajor, 10) || 0 : 0; + const m = vMinor ? parseInt(vMinor, 10) || 0 : 0; + const p = vPatch ? parseInt(vPatch, 10) || 0 : 0; + if (M > major) return true; + if (M < major) return false; + if (m > minor) return true; + if (m < minor) return false; + if (p > patch) return true; + if (p < patch) return false; + // Equal triplet: accept only non-prerelease + return !prerelease; } From 95af9f3c3dcbc865e45911a14e6a5a4fe68a7dbf Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 22 Sep 2025 00:11:24 +0800 Subject: [PATCH 15/19] format --- .../app/(org)/dashboard/caps/components/UploadCapButton.tsx | 2 +- apps/web/app/(org)/dashboard/folder/[id]/page.tsx | 2 +- apps/web/components/pages/_components/ComparePlans.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx index 6371ca685..6062c4526 100644 --- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx @@ -296,7 +296,7 @@ export const UploadCapButton = ({ // Create progress tracking state outside React const createProgressTracker = () => { - let uploadState = { + const uploadState = { videoId: uploadId, uploaded: 0, total: 0, diff --git a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx index e0973f051..c72635890 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx @@ -5,6 +5,7 @@ import { getFolderBreadcrumb, getVideosByFolderId, } from "@/lib/folder"; +import { UploadCapButton } from "../../caps/components"; import FolderCard from "../../caps/components/Folder"; import { BreadcrumbItem, @@ -12,7 +13,6 @@ import { NewSubfolderButton, } from "./components"; import FolderVideosSection from "./components/FolderVideosSection"; -import { UploadCapButton } from "../../caps/components"; const FolderPage = async ({ params }: { params: { id: Folder.FolderId } }) => { const [childFolders, breadcrumb, videosData] = await Promise.all([ diff --git a/apps/web/components/pages/_components/ComparePlans.tsx b/apps/web/components/pages/_components/ComparePlans.tsx index 51e741a09..c409767e3 100644 --- a/apps/web/components/pages/_components/ComparePlans.tsx +++ b/apps/web/components/pages/_components/ComparePlans.tsx @@ -10,9 +10,9 @@ import { toast } from "sonner"; import { useAuthContext } from "@/app/Layout/AuthContext"; import { CommercialArt, - CommercialArtRef, + type CommercialArtRef, } from "../HomePage/Pricing/CommercialArt"; -import { ProArt, ProArtRef } from "../HomePage/Pricing/ProArt"; +import { ProArt, type ProArtRef } from "../HomePage/Pricing/ProArt"; const COLUMN_WIDTH = "min-w-[200px]"; From 63dddf06624291c048d5f3c604d4b32b4ed44a5d Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 22 Sep 2025 00:20:02 +0800 Subject: [PATCH 16/19] fix flagging logic --- .../dashboard/caps/components/CapCard/CapCard.tsx | 8 +------- .../app/s/[videoId]/_components/ProgressCircle.tsx | 11 ++++++++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index b6cf2a41e..2a2078469 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -81,12 +81,6 @@ export interface CapCardProps extends PropsWithChildren { onDragEnd?: () => void; } -// localStorage.setItem("betaUploadProgress", "true"); -const enableBetaUploadProgress = - "localStorage" in globalThis - ? localStorage.getItem("betaUploadProgress") === "true" - : false; - export const CapCard = ({ cap, analytics, @@ -172,7 +166,7 @@ export const CapCard = ({ const uploadProgress = useUploadProgress( cap.id, - enableBetaUploadProgress && (cap.hasActiveUpload || false), + cap.hasActiveUpload || false, ); // Helper function to create a drag preview element diff --git a/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx b/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx index a160cfb3a..607aba32a 100644 --- a/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx +++ b/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx @@ -22,7 +22,16 @@ const MINUTE = 60 * SECOND; const HOUR = 60 * 60 * SECOND; const DAY = 24 * HOUR; -export function useUploadProgress(videoId: Video.VideoId, enabled: boolean) { +// TODO: Remove this once we are more confident in the feature +// localStorage.setItem("betaUploadProgress", "true"); +const enableBetaUploadProgress = + "localStorage" in globalThis + ? localStorage.getItem("betaUploadProgress") === "true" + : false; + +export function useUploadProgress(videoId: Video.VideoId, enabledRaw: boolean) { + const enabled = enableBetaUploadProgress ? enabledRaw : false; + const query = useEffectQuery({ queryKey: ["getUploadProgress", videoId], queryFn: () => From 89a9bf92fa4d5e756de2af6624fb4cf4a5680acf Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 22 Sep 2025 00:34:26 +0800 Subject: [PATCH 17/19] nit --- apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index 733589602..f906955a7 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -338,9 +338,7 @@ export function CapVideoPlayer({ "loadedmetadata", handleLoadedMetadataWithTracks, ); - if (retryTimeout.current) { - clearTimeout(retryTimeout.current); - } + if (retryTimeout.current) clearTimeout(retryTimeout.current); }; } From 046a6530df5624cf5bb5c827f8e88d9fd8854c60 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 22 Sep 2025 00:39:34 +0800 Subject: [PATCH 18/19] fix --- apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index f906955a7..76cbd487f 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -424,7 +424,7 @@ export function CapVideoPlayer({
Date: Mon, 22 Sep 2025 00:57:33 +0800 Subject: [PATCH 19/19] deploy plz --- .../web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx index b1812247e..8ebfa801e 100644 --- a/apps/web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/NewFolderDialog.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Button, Dialog,