diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx index 4275ca8..d1701ce 100644 --- a/src/components/layout/MobileNav.tsx +++ b/src/components/layout/MobileNav.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { @@ -10,6 +11,7 @@ import { User, ChevronDown, } from "lucide-react"; +import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet"; import { DropdownMenu, DropdownMenuContent, @@ -54,6 +56,7 @@ export function MobileNav({ pets }: MobileNavProps) { const { activePetId, setActivePet } = useActivePet(); const activePet = pets.find((p) => p.id === activePetId) ?? pets[0]; + const [postTypeSheetOpen, setPostTypeSheetOpen] = useState(false); // D-05: Home → /feed; also highlight when on / (which redirects to /feed) const isHomePage = pathname === "/feed" || pathname === "/"; @@ -153,16 +156,17 @@ export function MobileNav({ pets }: MobileNavProps) { Explore - {/* Post — center, accent-filled. Phase 1: navigates to /pets as placeholder */} - setPostTypeSheetOpen(true)} className="flex flex-col items-center" > - + {/* Notifications — placeholder, Phase 1 */} Profile + + {/* PostTypeSheet — D-01: bottom Sheet with Photo/Story/Milestone choices */} + ); } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 25a88cd..8dc36a1 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { @@ -10,7 +11,9 @@ import { MessageCircle, PawPrint, ChevronDown, + Plus, } from "lucide-react"; +import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet"; import { UserButton } from "@clerk/nextjs"; import { DropdownMenu, @@ -66,6 +69,7 @@ export function Sidebar({ pets }: SidebarProps) { const { activePetId, setActivePet } = useActivePet(); const activePet = pets.find((p) => p.id === activePetId) ?? pets[0]; + const [postTypeSheetOpen, setPostTypeSheetOpen] = useState(false); return ( ); } diff --git a/src/components/post-creation/MultiImageUpload.tsx b/src/components/post-creation/MultiImageUpload.tsx new file mode 100644 index 0000000..8912888 --- /dev/null +++ b/src/components/post-creation/MultiImageUpload.tsx @@ -0,0 +1,357 @@ +"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(null); + const [images, setImages] = useState([]); + 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((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) => { + 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 ( +
+ {/* Upload zone — shown when can add more images */} + {canAddMore && ( +
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" + > +
+ )} + + {/* Hidden file input */} + + + {/* Image thumbnails grid */} + {images.length > 0 && ( +
+ {images.map((img, i) => ( +
+ {/* Preview image */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Image + + {/* Uploading overlay */} + {img.status === "uploading" && ( +
+ +
+ )} + + {/* Error overlay */} + {img.status === "error" && ( +
+ Error +
+ )} + + {/* Progress bar at bottom */} + {img.status === "uploading" && ( +
+
+
+ )} + + {/* Remove button */} + {img.status !== "uploading" && ( + + )} + + {/* Order badge */} + + {i + 1} + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/post-creation/PhotoPostForm.tsx b/src/components/post-creation/PhotoPostForm.tsx new file mode 100644 index 0000000..da2c747 --- /dev/null +++ b/src/components/post-creation/PhotoPostForm.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { useActivePet } from "@/context/ActivePetContext"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { MultiImageUpload } from "@/components/post-creation/MultiImageUpload"; + +/** + * PhotoPostForm — multi-image upload + caption form for creating photo posts. + * + * Flow: + * 1. MultiImageUpload handles presigned URL + XHR PUT per image, collects storageKeys + * 2. Caption Textarea (optional, max 500) + * 3. "Share Post" CTA → posts.create({ petId, caption, imageKeys }) + * 4. onSuccess: invalidate feed query, close Dialog, router.push('/feed') (D-03) + * 5. Toasts: "Post shared" on success, error copy on failure + * + * UI-SPEC copy: + * Caption placeholder: "Write a caption…" + * Caption error: "Caption must be 500 characters or fewer." + * CTA: "Share Post" (bg-orange-500) + * Success toast: "Post shared" + * Error toast: "Couldn't share your post. Please try again." + * + * D-02: Form lives inside a full-screen Dialog (no URL change). + * D-03: After posting, close Dialog and navigate to /feed. + */ + +interface PhotoPostFormProps { + /** Called to close the parent Dialog */ + onClose: () => void; +} + +export function PhotoPostForm({ onClose }: PhotoPostFormProps) { + const trpc = useTRPC(); + const router = useRouter(); + const queryClient = useQueryClient(); + const { activePetId } = useActivePet(); + + const [imageKeys, setImageKeys] = useState([]); + const [caption, setCaption] = useState(""); + const [captionError, setCaptionError] = useState(null); + + const createMutation = useMutation( + trpc.posts.create.mutationOptions({ + onSuccess: () => { + // Invalidate feed query so new post appears at top (D-03) + queryClient.invalidateQueries({ queryKey: [["trpc", "feed", "getFeed"]] }); + onClose(); + // Navigate to /feed with new post at top (D-03) + router.push("/feed"); + toast.success("Post shared"); + }, + onError: () => { + toast.error("Couldn't share your post. Please try again."); + }, + }) + ); + + const handleCaptionChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setCaption(value); + if (value.length > 500) { + setCaptionError("Caption must be 500 characters or fewer."); + } else { + setCaptionError(null); + } + }, + [] + ); + + const handleSubmit = useCallback(() => { + if (!activePetId) { + toast.error("No active pet selected."); + return; + } + + if (imageKeys.length === 0) { + toast.error("Please add at least one photo."); + return; + } + + if (caption.length > 500) { + setCaptionError("Caption must be 500 characters or fewer."); + return; + } + + createMutation.mutate({ + petId: activePetId, + caption: caption.trim() || null, + imageKeys, + }); + }, [activePetId, imageKeys, caption, createMutation]); + + if (!activePetId) { + return ( +
+ No active pet selected. +
+ ); + } + + return ( +
+ {/* Multi-image upload zone */} + + + {/* Caption textarea */} +
+