diff --git a/src/components/pet/AvatarUpload.tsx b/src/components/pet/AvatarUpload.tsx index cf3d180..81222df 100644 --- a/src/components/pet/AvatarUpload.tsx +++ b/src/components/pet/AvatarUpload.tsx @@ -8,6 +8,7 @@ import { useTRPC } from "@/trpc/client"; import { useMutation } from "@tanstack/react-query"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Progress } from "@/components/ui/progress"; +import { uploadFileWithProgress } from "@/lib/upload-xhr"; /** * AvatarUpload — five-state upload zone for pet avatars. @@ -110,32 +111,10 @@ export function AvatarUpload({ } ); - // Step 2: PUT file directly to R2 using XHR for progress tracking - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = Math.round((event.loaded / event.total) * 100); - setUploadProgress(percent); - } - }; - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - reject(new Error(`R2 PUT failed with status ${xhr.status}`)); - } - }; - - xhr.onerror = () => reject(new Error("Network error during upload")); - - xhr.open("PUT", presignedUrl); - // Content-Type MUST match the contentType sent to getPresignedUrl - // Pitfall 4: mismatch causes R2 403 SignatureDoesNotMatch - xhr.setRequestHeader("Content-Type", file.type); - xhr.send(file); + // Step 2: PUT file directly to R2, tracking progress + await uploadFileWithProgress(presignedUrl, file, { + contentType: file.type, + onProgress: setUploadProgress, }); // Step 3: Confirm upload — write avatarKey to Pet DB row diff --git a/src/components/post-creation/MultiImageUpload.tsx b/src/components/post-creation/MultiImageUpload.tsx index 3cfd11e..9400b18 100644 --- a/src/components/post-creation/MultiImageUpload.tsx +++ b/src/components/post-creation/MultiImageUpload.tsx @@ -5,6 +5,7 @@ import { ImagePlus, X, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { useTRPC } from "@/trpc/client"; import { useMutation } from "@tanstack/react-query"; +import { uploadFileWithProgress } from "@/lib/upload-xhr"; /** * MultiImageUpload — multi-file upload zone for post images (up to 10). @@ -112,35 +113,16 @@ export function MultiImageUpload({ fileSize: file.size, }); - // Step 2: PUT file directly to Supabase Storage via XHR (with progress) - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percent = Math.round((event.loaded / event.total) * 100); - setImages((prev) => { - const updated = [...prev]; - updated[index] = { ...updated[index], uploadProgress: percent }; - return updated; - }); - } - }; - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - reject(new Error(`Storage PUT failed: ${xhr.status}`)); - } - }; - - xhr.onerror = () => reject(new Error("Network error during upload")); - - xhr.open("PUT", presignedUrl); - // Pitfall 4: Content-Type MUST exactly match what was sent to getPresignedUrl - xhr.setRequestHeader("Content-Type", file.type); - xhr.send(file); + // Step 2: PUT file directly to Supabase Storage, tracking progress + await uploadFileWithProgress(presignedUrl, file, { + contentType: file.type, + onProgress: (percent) => { + setImages((prev) => { + const updated = [...prev]; + updated[index] = { ...updated[index], uploadProgress: percent }; + return updated; + }); + }, }); // Step 3: Mark as done with storage key diff --git a/src/components/video/VideoUploadForm.tsx b/src/components/video/VideoUploadForm.tsx index ea95047..5844aa2 100644 --- a/src/components/video/VideoUploadForm.tsx +++ b/src/components/video/VideoUploadForm.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { useMutation } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { useActivePet } from "@/context/ActivePetContext"; +import { uploadFileWithProgress } from "@/lib/upload-xhr"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; @@ -78,22 +79,10 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) { caption: caption.trim() || null, }); - // PUT file directly to Mux upload URL - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - setUploadProgress(Math.round((event.loaded / event.total) * 100)); - } - }; - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) resolve(); - else reject(new Error(`Upload failed: ${xhr.status}`)); - }; - xhr.onerror = () => reject(new Error("Network error during upload")); - xhr.open("PUT", uploadUrl ?? ""); - // Mux direct upload accepts the video without a specific Content-Type header - xhr.send(file); + // PUT file directly to Mux upload URL, tracking progress + // (Mux direct upload accepts the video without a specific Content-Type header) + await uploadFileWithProgress(uploadUrl ?? "", file, { + onProgress: setUploadProgress, }); setUploadState("processing"); diff --git a/src/lib/avatar-url.ts b/src/lib/avatar-url.ts index 9fd5477..76e5651 100644 --- a/src/lib/avatar-url.ts +++ b/src/lib/avatar-url.ts @@ -1,8 +1,8 @@ +import { getMediaUrl } from "@/lib/media-url"; + /** * Constructs the public CDN URL for a pet avatar stored in Supabase Storage. - * Safe to use in both server and client components (uses NEXT_PUBLIC_ env var). + * Identical bucket/path scheme to getMediaUrl — kept as a separate export + * for call-site readability (avatar vs. post/story media). */ -export function getAvatarUrl(key: string | null | undefined): string | null { - if (!key) return null; - return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/pet-avatars/${key}`; -} +export const getAvatarUrl = getMediaUrl; diff --git a/src/lib/upload-xhr.ts b/src/lib/upload-xhr.ts new file mode 100644 index 0000000..bc97a6d --- /dev/null +++ b/src/lib/upload-xhr.ts @@ -0,0 +1,42 @@ +/** + * PUTs a file directly to a presigned/direct-upload URL via XHR, tracking + * progress. Shared by AvatarUpload, MultiImageUpload, and VideoUploadForm — + * all three PUT a File straight to storage after obtaining a signed URL + * from a tRPC mutation; only the surrounding state management differs. + * + * contentType is optional because Mux's direct-upload URL doesn't require + * a Content-Type header, while Supabase presigned URLs require it to + * exactly match what was sent to getPresignedUrl (mismatch → 403/400 + * SignatureDoesNotMatch — "Pitfall 4"). + */ +export function uploadFileWithProgress( + url: string, + file: File, + options?: { contentType?: string; onProgress?: (percent: number) => void } +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + options?.onProgress?.(Math.round((event.loaded / event.total) * 100)); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)); + } + }; + + xhr.onerror = () => reject(new Error("Network error during upload")); + + xhr.open("PUT", url); + if (options?.contentType) { + xhr.setRequestHeader("Content-Type", options.contentType); + } + xhr.send(file); + }); +}