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:
1 parent
485877a541
commit
1bd1a89c68
2 files changed
+374
-34
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in new issue
Block a user