From 1bd1a89c6880403d134038e297c378d596f8e210 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 6 Jun 2026 11:08:56 +0200 Subject: [PATCH] feat(01-04): AvatarUpload five-state component + wire into PetForm - Create AvatarUpload.tsx: idle/dragging/uploading/error/idle-with-avatar states - XHR PUT to R2 presigned URL with progress tracking (Content-Type must match presignedUrl) - Two-step upload: getPresignedUrl -> XHR PUT -> confirmUpload -> CDN URL display - Client-side validation: size <= 10MB, type jpeg/png/webp - Disabled state in create mode (no petId) with tooltip - Drag and drop: dragOver/dragLeave/onDrop handlers; orange border in dragging state - Wire AvatarUpload into PetForm replacing AvatarUploadZone stub - Add initialAvatarKey prop and currentAvatarKey state to PetForm - onUploadComplete invalidates React Query cache (pets.byId + pets.list) --- src/components/pet/AvatarUpload.tsx | 350 ++++++++++++++++++++++++++++ src/components/pet/PetForm.tsx | 58 ++--- 2 files changed, 374 insertions(+), 34 deletions(-) create mode 100644 src/components/pet/AvatarUpload.tsx diff --git a/src/components/pet/AvatarUpload.tsx b/src/components/pet/AvatarUpload.tsx new file mode 100644 index 0000000..5ccf0c8 --- /dev/null +++ b/src/components/pet/AvatarUpload.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import { Camera, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +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"; + +/** + * AvatarUpload — five-state upload zone for pet avatars. + * + * States: idle | idle-with-avatar | dragging | uploading | error + * + * Upload flow (Architecture constraint — image bytes never pass through Next.js API): + * 1. File validated client-side (size <= 10MB, type jpeg/png/webp) + * 2. media.getPresignedUrl tRPC mutation → { presignedUrl, key } + * 3. XMLHttpRequest PUT directly to R2 presigned URL (tracks upload progress) + * 4. media.confirmUpload tRPC mutation → writes avatarKey to Pet DB row + * 5. CDN URL constructed and displayed; onUploadComplete callback fired + * + * Security: + * - No R2 credentials in this client component (T-4-03) + * - contentType sent to presignedUrl MUST match Content-Type in PUT (Pitfall 4 prevention) + * - NEXT_PUBLIC_R2_PUBLIC_URL is the CDN domain only — not credentials + * + * Props: + * - petId: string | undefined — undefined in create mode (upload disabled until pet saved) + * - currentAvatarUrl: CDN URL of existing avatar (null if none) + * - onUploadComplete: callback with the new R2 key after successful upload + */ + +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 + +interface AvatarUploadProps { + petId: string | undefined; + currentAvatarUrl?: string | null; + onUploadComplete?: (key: string) => void; +} + +type UploadState = "idle" | "dragging" | "uploading" | "error"; + +export function AvatarUpload({ + petId, + currentAvatarUrl, + onUploadComplete, +}: AvatarUploadProps) { + const trpc = useTRPC(); + const fileInputRef = useRef(null); + const [uploadState, setUploadState] = useState("idle"); + const [uploadProgress, setUploadProgress] = useState(0); + const [errorMessage, setErrorMessage] = useState(null); + const [localAvatarUrl, setLocalAvatarUrl] = useState( + currentAvatarUrl ?? null + ); + + const getPresignedUrlMutation = useMutation({ + ...trpc.media.getPresignedUrl.mutationOptions(), + }); + + const confirmUploadMutation = useMutation({ + ...trpc.media.confirmUpload.mutationOptions(), + }); + + const isDisabled = !petId; + const hasAvatar = !!localAvatarUrl; + + const handleError = useCallback((message: string) => { + setUploadState("error"); + setErrorMessage(message); + toast.error(message); + }, []); + + const uploadFile = useCallback( + async (file: File) => { + // Disabled state guard — petId not yet available (create mode) + if (!petId) { + toast.info("Save your pet first to upload a photo."); + return; + } + + // Client-side validation: file size + if (file.size > MAX_FILE_SIZE) { + handleError("Photo must be under 10 MB."); + return; + } + + // Client-side validation: file type + if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) { + handleError("Upload a JPG, PNG, or WebP image."); + return; + } + + setUploadState("uploading"); + setUploadProgress(0); + setErrorMessage(null); + + try { + // Step 1: Get presigned URL from tRPC + const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync( + { + petId, + contentType: file.type as AllowedContentType, + fileSize: file.size, + } + ); + + // 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 3: Confirm upload — write avatarKey to Pet DB row + await confirmUploadMutation.mutateAsync({ petId, key }); + + // Step 4: Show CDN avatar URL immediately (no page reload needed) + const cdnUrl = `${process.env.NEXT_PUBLIC_R2_PUBLIC_URL}/${key}`; + setLocalAvatarUrl(cdnUrl); + setUploadState("idle"); + setUploadProgress(0); + + // Step 5: Notify parent and show success toast + onUploadComplete?.(key); + toast.success("Profile photo updated"); + } catch { + handleError("Upload failed — please try again"); + setUploadState("error"); + } + }, + [ + petId, + getPresignedUrlMutation, + confirmUploadMutation, + onUploadComplete, + handleError, + ] + ); + + const handleFileChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + void uploadFile(file); + } + // Reset input so same file can be re-selected after error + event.target.value = ""; + }, + [uploadFile] + ); + + const handleClick = useCallback(() => { + if (!isDisabled) { + fileInputRef.current?.click(); + } + }, [isDisabled]); + + const handleDragOver = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + if (!isDisabled) { + setUploadState("dragging"); + } + }, + [isDisabled] + ); + + const handleDragLeave = useCallback(() => { + setUploadState("idle"); + }, []); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + setUploadState("idle"); + const file = event.dataTransfer.files[0]; + if (file) { + void uploadFile(file); + } + }, + [uploadFile] + ); + + // Hidden file input — triggers when zone is clicked + const fileInput = ( + + ); + + // ── Disabled state (create mode — no petId yet) ─────────────────────── + if (isDisabled) { + return ( +
+
+ ); + } + + // ── Uploading state ──────────────────────────────────────────────────── + if (uploadState === "uploading") { + return ( +
+ {fileInput} +
+ + {localAvatarUrl ? ( + + ) : ( + + + + )} + + {/* Spinner overlay */} +
+ +
+ {/* Progress bar at bottom */} +
+ +
+
+ + {uploadProgress > 0 ? `${uploadProgress}%` : "Uploading…"} + +
+ ); + } + + // ── Idle with existing avatar (hover to change) ──────────────────────── + if (hasAvatar) { + return ( +
+ {fileInput} +
{ + if (e.key === "Enter" || e.key === " ") handleClick(); + }} + aria-label="Change profile photo" + > + + + + + + + {/* Hover overlay */} +
+
+
+ {uploadState === "error" && errorMessage && ( +

+ {errorMessage} +

+ )} +
+ ); + } + + // ── Idle / dragging — no avatar yet ─────────────────────────────────── + const isDragging = uploadState === "dragging"; + const isError = uploadState === "error"; + + return ( +
+ {fileInput} +
{ + if (e.key === "Enter" || e.key === " ") handleClick(); + }} + aria-label="Upload profile photo" + > +
+ {isError && errorMessage && ( +

+ {errorMessage} +

+ )} +
+ ); +} diff --git a/src/components/pet/PetForm.tsx b/src/components/pet/PetForm.tsx index e9c405e..e2f7f43 100644 --- a/src/components/pet/PetForm.tsx +++ b/src/components/pet/PetForm.tsx @@ -5,7 +5,7 @@ import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { toast } from "sonner"; -import { Loader2, Camera } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { Button } from "@/components/ui/button"; @@ -20,6 +20,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; +import { AvatarUpload } from "@/components/pet/AvatarUpload"; /** * PetForm — reusable create/edit form for pet profiles. @@ -27,7 +28,7 @@ import { Skeleton } from "@/components/ui/skeleton"; * Covers PET-01 (create) and PET-02 (edit) requirements. * Species and breeds are fetched from DB — never hardcoded (anti-pattern prevention). * Zod schema enforces character limits at the form layer (server-side also enforces via tRPC T-3-02). - * Avatar upload zone stub is rendered; wired in Plan 04. + * AvatarUpload is functional in edit mode; disabled in create mode until pet is saved. */ const petFormSchema = z.object({ @@ -55,42 +56,15 @@ interface PetFormProps { mode: "create" | "edit"; initialValues?: Partial; petId?: string; + initialAvatarKey?: string | null; onSuccess?: (petId: string) => void; } -/** - * Avatar upload zone stub — Plan 04 will wire the actual upload logic. - * In create mode, petId is undefined so upload is disabled. - */ -function AvatarUploadZone({ petId }: { petId?: string }) { - const isDisabled = !petId; - - return ( -
- - Upload photo - {isDisabled && ( - - Save your pet first to upload a photo. - - )} -
- ); -} - export function PetForm({ mode, initialValues, petId, + initialAvatarKey, onSuccess, }: PetFormProps) { const trpc = useTRPC(); @@ -98,6 +72,10 @@ export function PetForm({ const [selectedSpeciesId, setSelectedSpeciesId] = useState( initialValues?.speciesId ?? "" ); + // Track the current avatarKey so we can construct the CDN URL for AvatarUpload + const [currentAvatarKey, setCurrentAvatarKey] = useState( + initialAvatarKey ?? null + ); const { data: speciesList = [], isLoading: speciesLoading } = useQuery({ ...trpc.pets.listSpecies.queryOptions(), @@ -182,9 +160,21 @@ export function PetForm({ return (
- {/* Avatar upload zone (stub — Plan 04 wires actual upload) */} -
- + {/* Avatar upload zone — functional in edit mode, disabled in create mode */} +
+ { + setCurrentAvatarKey(key); + // Invalidate both pets.byId (profile) and pets.list (sidebar) + void queryClient.invalidateQueries(); + }} + />
{/* Pet name */}