Files
petfeed/src/components/post-creation/MultiImageUpload.tsx
T
adminandClaude Sonnet 4.6 e4b619671f feat(02-04): post creation flow — MultiImageUpload, PhotoPostForm, PostTypeSheet, + button nav
MultiImageUpload:
- Per-file: validate type(jpg/png/webp) + size(<10MB) with exact UI-SPEC error copy
- Rejects >10 total: "You can upload up to 10 photos per post."
- posts.getPresignedUrl → XHR PUT with exact Content-Type (Pitfall 4 prevention)
- Reorderable thumbnails with remove button and upload progress bar

PhotoPostForm:
- MultiImageUpload + caption Textarea (max 500, error "Caption must be 500 characters or fewer.")
- "Share Post" CTA (bg-orange-500) → posts.create({ petId: activePetId, imageKeys })
- onSuccess: invalidate feed query, close Dialog, router.push('/feed') (D-03)
- Toasts: "Post shared" / "Couldn't share your post. Please try again."

PostTypeSheet:
- Bottom Sheet titled "Create" with 3 options: Photo Post, Story, Milestone (D-01)
- Photo → full-screen Dialog with PhotoPostForm (D-02, no URL change)
- Story/Milestone: TODO(02-05)/TODO(02-06) seams with placeholder dialogs

Sidebar + MobileNav:
- + button opens PostTypeSheet (D-01)
- MobileNav center + button: was Link to /pets, now triggers PostTypeSheet
- Sidebar: New Post button added above owner identity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 12:48:56 +02:00

358 lines
11 KiB
TypeScript

"use client";
import { useState, useRef, useCallback } from "react";
import { ImagePlus, X, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { useMutation } from "@tanstack/react-query";
/**
* MultiImageUpload — multi-file upload zone for post images (up to 10).
*
* Adapts AvatarUpload to multiple files. For each selected file:
* 1. Validate type (jpg/png/webp) + size (<10MB) with exact UI-SPEC error copy
* 2. Reject >10 total: "You can upload up to 10 photos per post."
* 3. posts.getPresignedUrl → XHR PUT to Supabase signed URL with exact Content-Type (Pitfall 4)
* 4. Collect returned storage keys
*
* Pitfall 4 prevention: Content-Type header in XHR PUT MUST exactly match the contentType
* passed to getPresignedUrl (Supabase returns 400 SignatureDoesNotMatch if mismatched).
*
* UI-SPEC copy:
* Upload zone: "Add photos" / "Drag photos here or tap to browse — up to 10"
* File too large: "Photo must be under 10 MB."
* Wrong format: "Upload a JPG, PNG, or WebP image."
* Too many images: "You can upload up to 10 photos per post."
*/
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
type AllowedContentType = (typeof ALLOWED_TYPES)[number];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_IMAGES = 10;
interface UploadedImage {
/** Local preview URL (object URL) */
previewUrl: string;
/** Supabase storage key — only set after successful upload */
storageKey: string | null;
/** File size for display */
fileName: string;
/** Upload state */
status: "pending" | "uploading" | "done" | "error";
uploadProgress: number;
}
interface MultiImageUploadProps {
petId: string;
/** Called with all successfully uploaded storage keys */
onKeysChange: (keys: string[]) => void;
/** Max number of images (default 10) */
maxImages?: number;
}
export function MultiImageUpload({
petId,
onKeysChange,
maxImages = MAX_IMAGES,
}: MultiImageUploadProps) {
const trpc = useTRPC();
const fileInputRef = useRef<HTMLInputElement>(null);
const [images, setImages] = useState<UploadedImage[]>([]);
const [isDragging, setIsDragging] = useState(false);
const getPresignedUrlMutation = useMutation(
trpc.posts.getPresignedUrl.mutationOptions()
);
const uploadFile = useCallback(
async (file: File, index: number) => {
// Validate size
if (file.size > MAX_FILE_SIZE) {
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "error" };
return updated;
});
toast.error("Photo must be under 10 MB.");
return null;
}
// Validate type
if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) {
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "error" };
return updated;
});
toast.error("Upload a JPG, PNG, or WebP image.");
return null;
}
// Mark as uploading
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "uploading", uploadProgress: 0 };
return updated;
});
try {
// Step 1: Get presigned URL
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
petId,
contentType: file.type as AllowedContentType,
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 3: Mark as done with storage key
setImages((prev) => {
const updated = [...prev];
updated[index] = {
...updated[index],
status: "done",
storageKey: key,
uploadProgress: 100,
};
// Notify parent with all successfully uploaded keys
const keys = updated
.filter((img) => img.status === "done" && img.storageKey)
.map((img) => img.storageKey as string);
onKeysChange(keys);
return updated;
});
return key;
} catch {
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "error" };
return updated;
});
toast.error("Upload failed — please try again.");
return null;
}
},
[petId, getPresignedUrlMutation, onKeysChange]
);
const processFiles = useCallback(
async (files: File[]) => {
const currentCount = images.length;
const available = maxImages - currentCount;
if (available <= 0) {
toast.error("You can upload up to 10 photos per post.");
return;
}
const toUpload = files.slice(0, available);
if (files.length > available) {
toast.error("You can upload up to 10 photos per post.");
}
// Create preview entries immediately
const newImages: UploadedImage[] = toUpload.map((file) => ({
previewUrl: URL.createObjectURL(file),
storageKey: null,
fileName: file.name,
status: "pending",
uploadProgress: 0,
}));
setImages((prev) => {
const updated = [...prev, ...newImages];
return updated;
});
// Upload each file
const startIndex = currentCount;
await Promise.all(
toUpload.map((file, i) => uploadFile(file, startIndex + i))
);
},
[images.length, maxImages, uploadFile]
);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
if (files.length > 0) {
void processFiles(files);
}
event.target.value = "";
},
[processFiles]
);
const handleRemove = useCallback(
(index: number) => {
setImages((prev) => {
const updated = prev.filter((_, i) => i !== index);
// Release object URL to avoid memory leaks
URL.revokeObjectURL(prev[index].previewUrl);
const keys = updated
.filter((img) => img.status === "done" && img.storageKey)
.map((img) => img.storageKey as string);
onKeysChange(keys);
return updated;
});
},
[onKeysChange]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback(() => {
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
void processFiles(files);
}
},
[processFiles]
);
const canAddMore = images.length < maxImages;
return (
<div className="space-y-3">
{/* Upload zone — shown when can add more images */}
{canAddMore && (
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragging
? "border-orange-500 bg-orange-50/50"
: "border-muted hover:border-muted-foreground/50"
}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
fileInputRef.current?.click();
}
}}
aria-label="Add photos"
>
<ImagePlus className="h-8 w-8 text-muted-foreground mx-auto mb-2" aria-hidden="true" />
<p className="text-sm font-medium text-foreground">Add photos</p>
<p className="text-xs text-muted-foreground mt-1">
Drag photos here or tap to browse — up to 10
</p>
</div>
)}
{/* Hidden file input */}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
{/* Image thumbnails grid */}
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{images.map((img, i) => (
<div key={i} className="relative aspect-square rounded-md overflow-hidden bg-muted">
{/* Preview image */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img.previewUrl}
alt={`Image ${i + 1}`}
className="w-full h-full object-cover"
/>
{/* Uploading overlay */}
{img.status === "uploading" && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Loader2 className="h-6 w-6 text-white animate-spin" />
</div>
)}
{/* Error overlay */}
{img.status === "error" && (
<div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
<span className="text-xs text-white font-medium">Error</span>
</div>
)}
{/* Progress bar at bottom */}
{img.status === "uploading" && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-muted">
<div
className="h-full bg-orange-500 transition-all"
style={{ width: `${img.uploadProgress}%` }}
/>
</div>
)}
{/* Remove button */}
{img.status !== "uploading" && (
<button
type="button"
onClick={() => handleRemove(i)}
className="absolute top-1 right-1 w-5 h-5 bg-black/60 rounded-full flex items-center justify-center hover:bg-black/80 transition-colors"
aria-label={`Remove image ${i + 1}`}
>
<X className="h-3 w-3 text-white" />
</button>
)}
{/* Order badge */}
<span className="absolute bottom-1 left-1 text-xs text-white bg-black/50 rounded px-1 select-none">
{i + 1}
</span>
</div>
))}
</div>
)}
</div>
);
}