From 3db069291019460719798c0fc54032cac04418bb Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Tue, 21 Jul 2026 20:56:15 +0200 Subject: [PATCH] refactor(cluster-d): merge getAvatarUrl/getMediaUrl, extract shared upload-progress XHR helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAvatarUrl and getMediaUrl were byte-identical bucket URL builders in two separate files. getAvatarUrl is now a re-export of getMediaUrl — all ~34 call sites keep working unchanged. AvatarUpload, MultiImageUpload, and VideoUploadForm each duplicated the same XHR PUT-with-progress Promise wrapper. The three flows differ too much (single vs. multi-file, with/without a confirm step) for a single useFileUpload hook to be a clean fit, so only the truly identical piece — the XHR PUT itself — was extracted into uploadFileWithProgress(). --- src/components/pet/AvatarUpload.tsx | 31 +++----------- .../post-creation/MultiImageUpload.tsx | 40 +++++------------- src/components/video/VideoUploadForm.tsx | 21 +++------- src/lib/avatar-url.ts | 10 ++--- src/lib/upload-xhr.ts | 42 +++++++++++++++++++ 5 files changed, 68 insertions(+), 76 deletions(-) create mode 100644 src/lib/upload-xhr.ts 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); + }); +}