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 (
+
+ );
+}
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