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:
admin committed 2026-07-21 20:56:15 +02:00
1 parent 1461470246
commit 3db0692910
5 files changed
+68 -76

No files matched your search

+5 -26
View File
@@ -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
+5 -16
View File
@@ -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");
+5 -5
View File
@@ -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;
+42
View File
@@ -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);
});
}