diff --git a/package-lock.json b/package-lock.json index 5733fa9..f30c65d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "prisma": "^7.8.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-easy-crop": "^6.2.3", "react-hook-form": "^7.77.0", "react-intersection-observer": "^10.0.3", "server-only": "^0.0.1", @@ -12524,6 +12525,12 @@ "node": ">=6.0.0" } }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", @@ -13587,6 +13594,19 @@ "react": "^19.2.4" } }, + "node_modules/react-easy-crop": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.2.3.tgz", + "integrity": "sha512-ebimG3OGlzizjxEZ77Cj9CVLcg5vDZ6QVz8ud1rx4WmsCDWHIROEhgMi0GpJ/jKAuwHrH0M+QZUK4hyvxbYhOA==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, "node_modules/react-hook-form": { "version": "7.77.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.77.0.tgz", diff --git a/package.json b/package.json index ec67ceb..c26f774 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "prisma": "^7.8.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-easy-crop": "^6.2.3", "react-hook-form": "^7.77.0", "react-intersection-observer": "^10.0.3", "server-only": "^0.0.1", diff --git a/src/components/pet/AvatarCropDialog.tsx b/src/components/pet/AvatarCropDialog.tsx new file mode 100644 index 0000000..3d8a8dd --- /dev/null +++ b/src/components/pet/AvatarCropDialog.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useCallback, useState } from "react"; +import Cropper, { type Area } from "react-easy-crop"; +import { useTranslations } from "next-intl"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { getCroppedImageBlob } from "@/lib/crop-image"; + +interface AvatarCropDialogProps { + imageSrc: string; + mimeType: string; + onCancel: () => void; + onCropped: (blob: Blob) => void; +} + +/** + * Circular crop step between file selection and upload — cropShape="round" + * gives the darkened-outside-the-circle overlay for free (react-easy-crop's + * built-in mask), no hand-rolled canvas overlay needed. + */ +export function AvatarCropDialog({ + imageSrc, + mimeType, + onCancel, + onCropped, +}: AvatarCropDialogProps) { + const t = useTranslations("AvatarUpload"); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [cropAreaPixels, setCropAreaPixels] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + + const handleCropComplete = useCallback((_area: Area, areaPixels: Area) => { + setCropAreaPixels(areaPixels); + }, []); + + const handleConfirm = useCallback(async () => { + if (!cropAreaPixels) return; + setIsProcessing(true); + try { + const blob = await getCroppedImageBlob(imageSrc, cropAreaPixels, mimeType); + onCropped(blob); + } finally { + setIsProcessing(false); + } + }, [cropAreaPixels, imageSrc, mimeType, onCropped]); + + return ( + { + if (!next) onCancel(); + }} + > + + + {t("cropTitle")} + + +
+ +
+ +
+ {t("zoom")} + setZoom(Number(e.target.value))} + className="w-full accent-orange-500" + aria-label={t("zoom")} + /> +
+ + + + + +
+
+ ); +} diff --git a/src/components/pet/AvatarUpload.tsx b/src/components/pet/AvatarUpload.tsx index e275faf..8b0ca92 100644 --- a/src/components/pet/AvatarUpload.tsx +++ b/src/components/pet/AvatarUpload.tsx @@ -9,6 +9,7 @@ import { useMutation } from "@tanstack/react-query"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Progress } from "@/components/ui/progress"; import { uploadFileWithProgress } from "@/lib/upload-xhr"; +import { AvatarCropDialog } from "@/components/pet/AvatarCropDialog"; /** * AvatarUpload — five-state upload zone for pet avatars. @@ -17,10 +18,12 @@ import { uploadFileWithProgress } from "@/lib/upload-xhr"; * * 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 the Supabase Storage 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 + * 2. AvatarCropDialog opens (circular crop + zoom) — canvas re-encodes the + * selected region to the original file's mime type before upload + * 3. media.getPresignedUrl tRPC mutation → { presignedUrl, key } + * 4. XMLHttpRequest PUT directly to the Supabase Storage presigned URL (tracks upload progress) + * 5. media.confirmUpload tRPC mutation → writes avatarKey to Pet DB row + * 6. CDN URL constructed and displayed; onUploadComplete callback fired * * Security: * - No Supabase Storage credentials in this client component (T-4-03) @@ -59,6 +62,9 @@ export function AvatarUpload({ const [localAvatarUrl, setLocalAvatarUrl] = useState( currentAvatarUrl ?? null ); + const [pendingCrop, setPendingCrop] = useState<{ file: File; objectUrl: string } | null>( + null + ); const getPresignedUrlMutation = useMutation({ ...trpc.media.getPresignedUrl.mutationOptions(), @@ -79,24 +85,13 @@ export function AvatarUpload({ const uploadFile = useCallback( async (file: File) => { - // Disabled state guard — petId not yet available (create mode) + // Type-narrowing guard — selectFile() already gates on petId before the + // crop dialog ever opens, so this only protects the TS type of petId. if (!petId) { toast.info(t("savePetFirst")); return; } - // Client-side validation: file size - if (file.size > MAX_FILE_SIZE) { - handleError(t("fileTooLarge")); - return; - } - - // Client-side validation: file type - if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) { - handleError(t("invalidType")); - return; - } - setUploadState("uploading"); setUploadProgress(0); setErrorMessage(null); @@ -143,16 +138,55 @@ export function AvatarUpload({ ] ); + // Validates a freshly picked/dropped file and opens the crop dialog — + // the actual upload only fires once the user confirms a crop. + const selectFile = useCallback( + (file: File) => { + if (!petId) { + toast.info(t("savePetFirst")); + return; + } + if (file.size > MAX_FILE_SIZE) { + handleError(t("fileTooLarge")); + return; + } + if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) { + handleError(t("invalidType")); + return; + } + setPendingCrop({ file, objectUrl: URL.createObjectURL(file) }); + }, + [petId, t, handleError] + ); + + const closeCrop = useCallback(() => { + if (pendingCrop) URL.revokeObjectURL(pendingCrop.objectUrl); + setPendingCrop(null); + }, [pendingCrop]); + + const handleCropped = useCallback( + (blob: Blob) => { + if (!pendingCrop) return; + const croppedFile = new File([blob], pendingCrop.file.name, { + type: pendingCrop.file.type, + }); + URL.revokeObjectURL(pendingCrop.objectUrl); + setPendingCrop(null); + void uploadFile(croppedFile); + }, + [pendingCrop, uploadFile] + ); + const handleFileChange = useCallback( (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { - void uploadFile(file); + selectFile(file); } // Reset input so same file can be re-selected after error event.target.value = ""; }, - [uploadFile] + [selectFile] ); const handleClick = useCallback(() => { @@ -181,10 +215,10 @@ export function AvatarUpload({ setUploadState("idle"); const file = event.dataTransfer.files[0]; if (file) { - void uploadFile(file); + selectFile(file); } }, - [uploadFile] + [selectFile] ); // Hidden file input — triggers when zone is clicked @@ -288,6 +322,14 @@ export function AvatarUpload({ {errorMessage}

)} + {pendingCrop && ( + + )} ); } @@ -326,6 +368,14 @@ export function AvatarUpload({ {errorMessage}

)} + {pendingCrop && ( + + )} ); } diff --git a/src/lib/crop-image.ts b/src/lib/crop-image.ts new file mode 100644 index 0000000..c05cb3f --- /dev/null +++ b/src/lib/crop-image.ts @@ -0,0 +1,54 @@ +import type { Area } from "react-easy-crop"; + +function loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => resolve(image)); + image.addEventListener("error", reject); + image.crossOrigin = "anonymous"; + image.src = url; + }); +} + +/** + * Renders the cropped region of an image onto an off-screen canvas and + * returns it as a Blob — the standard react-easy-crop output recipe. + */ +export async function getCroppedImageBlob( + imageSrc: string, + cropAreaPixels: Area, + mimeType: string +): Promise { + const image = await loadImage(imageSrc); + const canvas = document.createElement("canvas"); + canvas.width = cropAreaPixels.width; + canvas.height = cropAreaPixels.height; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Canvas 2D context not available"); + } + + ctx.drawImage( + image, + cropAreaPixels.x, + cropAreaPixels.y, + cropAreaPixels.width, + cropAreaPixels.height, + 0, + 0, + cropAreaPixels.width, + cropAreaPixels.height + ); + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (blob) resolve(blob); + else reject(new Error("Canvas produced an empty blob")); + }, + mimeType, + 0.92 + ); + }); +}