refactor(cluster-d): merge getAvatarUrl/getMediaUrl, extract shared upload-progress XHR helper
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().
This commit is contained in:
1 parent
1461470246
commit
3db0692910
5 files changed
+68
-76
No files matched your search
@@ -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<void>((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
|
||||
|
||||
@@ -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<void>((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
|
||||
|
||||
@@ -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<void>((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");
|
||||
|
||||
@@ -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;
|
||||
@@ -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<void> {
|
||||
return new Promise<void>((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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user