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)
This commit is contained in:
admin committed 2026-06-06 11:08:56 +02:00
1 parent 485877a541
commit 1bd1a89c68
2 files changed
+374 -34

No files matched your search

+350
View File
@@ -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<HTMLInputElement>(null);
const [uploadState, setUploadState] = useState<UploadState>("idle");
const [uploadProgress, setUploadProgress] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [localAvatarUrl, setLocalAvatarUrl] = useState<string | null>(
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<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 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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
event.preventDefault();
if (!isDisabled) {
setUploadState("dragging");
}
},
[isDisabled]
);
const handleDragLeave = useCallback(() => {
setUploadState("idle");
}, []);
const handleDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
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 = (
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
);
// ── Disabled state (create mode — no petId yet) ───────────────────────
if (isDisabled) {
return (
<div
title="Save your pet profile first, then add a photo."
className="flex flex-col items-center justify-center gap-2 rounded-full w-32 h-32 border-dashed border-2 border-muted opacity-50 cursor-not-allowed mx-auto"
>
<Camera className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<span className="text-xs text-muted-foreground">Upload photo</span>
</div>
);
}
// ── Uploading state ────────────────────────────────────────────────────
if (uploadState === "uploading") {
return (
<div className="relative flex flex-col items-center gap-2 mx-auto">
{fileInput}
<div className="relative w-32 h-32">
<Avatar className="w-32 h-32">
{localAvatarUrl ? (
<AvatarImage
src={localAvatarUrl}
alt="Uploading..."
className="opacity-50"
/>
) : (
<AvatarFallback className="bg-muted opacity-50">
<Camera className="h-8 w-8 text-muted-foreground" />
</AvatarFallback>
)}
</Avatar>
{/* Spinner overlay */}
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/30">
<Loader2 className="h-8 w-8 text-white animate-spin" />
</div>
{/* Progress bar at bottom */}
<div className="absolute bottom-0 left-0 right-0">
<Progress
value={uploadProgress}
className="h-1 rounded-none rounded-b-full [&>div]:bg-orange-500"
/>
</div>
</div>
<span className="text-xs text-muted-foreground">
{uploadProgress > 0 ? `${uploadProgress}%` : "Uploading…"}
</span>
</div>
);
}
// ── Idle with existing avatar (hover to change) ────────────────────────
if (hasAvatar) {
return (
<div className="flex flex-col items-center gap-2 mx-auto">
{fileInput}
<div
className={`relative w-32 h-32 cursor-pointer group ${
uploadState === "dragging"
? "ring-2 ring-orange-500 ring-offset-2 rounded-full"
: ""
}`}
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleClick();
}}
aria-label="Change profile photo"
>
<Avatar className="w-32 h-32">
<AvatarImage src={localAvatarUrl!} alt="Pet avatar" />
<AvatarFallback className="bg-muted">
<Camera className="h-8 w-8 text-muted-foreground" />
</AvatarFallback>
</Avatar>
{/* Hover overlay */}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="h-6 w-6 text-white" aria-hidden="true" />
<span className="text-xs text-white font-medium">Change photo</span>
</div>
</div>
{uploadState === "error" && errorMessage && (
<p className="text-xs text-destructive text-center max-w-[160px]">
{errorMessage}
</p>
)}
</div>
);
}
// ── Idle / dragging — no avatar yet ───────────────────────────────────
const isDragging = uploadState === "dragging";
const isError = uploadState === "error";
return (
<div className="flex flex-col items-center gap-2 mx-auto">
{fileInput}
<div
className={`flex flex-col items-center justify-center gap-2 rounded-full w-32 h-32 border-dashed border-2 cursor-pointer transition-colors ${
isDragging
? "border-orange-500 bg-orange-50/50"
: isError
? "border-red-500"
: "border-muted hover:border-muted-foreground/50"
}`}
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleClick();
}}
aria-label="Upload profile photo"
>
<Camera className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<span className="text-xs text-muted-foreground">Upload photo</span>
</div>
{isError && errorMessage && (
<p className="text-xs text-destructive text-center max-w-[160px]">
{errorMessage}
</p>
)}
</div>
);
}
+24 -34
View File
@@ -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<PetFormValues>;
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 (
<div
title={
isDisabled ? "Save your pet first to upload a photo." : undefined
}
className={`flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-muted p-6 text-sm text-muted-foreground transition-colors ${
isDisabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:border-muted-foreground/50"
}`}
>
<Camera className="size-6" />
<span>Upload photo</span>
{isDisabled && (
<span className="text-xs">
Save your pet first to upload a photo.
</span>
)}
</div>
);
}
export function PetForm({
mode,
initialValues,
petId,
initialAvatarKey,
onSuccess,
}: PetFormProps) {
const trpc = useTRPC();
@@ -98,6 +72,10 @@ export function PetForm({
const [selectedSpeciesId, setSelectedSpeciesId] = useState<string>(
initialValues?.speciesId ?? ""
);
// Track the current avatarKey so we can construct the CDN URL for AvatarUpload
const [currentAvatarKey, setCurrentAvatarKey] = useState<string | null>(
initialAvatarKey ?? null
);
const { data: speciesList = [], isLoading: speciesLoading } = useQuery({
...trpc.pets.listSpecies.queryOptions(),
@@ -182,9 +160,21 @@ export function PetForm({
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{/* Avatar upload zone (stub — Plan 04 wires actual upload) */}
<div className="flex flex-col gap-1.5">
<AvatarUploadZone petId={petId} />
{/* Avatar upload zone — functional in edit mode, disabled in create mode */}
<div className="flex flex-col items-center gap-1.5">
<AvatarUpload
petId={petId}
currentAvatarUrl={
currentAvatarKey
? `${process.env.NEXT_PUBLIC_R2_PUBLIC_URL}/${currentAvatarKey}`
: null
}
onUploadComplete={(key) => {
setCurrentAvatarKey(key);
// Invalidate both pets.byId (profile) and pets.list (sidebar)
void queryClient.invalidateQueries();
}}
/>
</div>
{/* Pet name */}