From 6fd5f2dff8cd5965380c09894dca75f538ec3409 Mon Sep 17 00:00:00 2001
From: neodk2004
Date: Wed, 12 Aug 2026 12:52:32 +0200
Subject: [PATCH] feat(pet): circular avatar cropping with zoom before upload
Adds an AvatarCropDialog step (react-easy-crop, cropShape="round")
between file selection and the presigned-URL upload in AvatarUpload:
the user crops/zooms a circular region, which is re-encoded via canvas
to the original file's mime type before the existing upload flow runs.
File-size/type validation moved from uploadFile into the new
selectFile gate that opens the dialog.
Gitea #13
---
package-lock.json | 20 +++++
package.json | 1 +
src/components/pet/AvatarCropDialog.tsx | 110 ++++++++++++++++++++++++
src/components/pet/AvatarUpload.tsx | 92 +++++++++++++++-----
src/lib/crop-image.ts | 54 ++++++++++++
5 files changed, 256 insertions(+), 21 deletions(-)
create mode 100644 src/components/pet/AvatarCropDialog.tsx
create mode 100644 src/lib/crop-image.ts
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(
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
+ );
+ });
+}