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
This commit is contained in:
admin committed 2026-08-12 12:52:32 +02:00
1 parent 5fbf39593d
commit 6fd5f2dff8
5 files changed
+256 -21

No files matched your search

+20
View File
@@ -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",
+1
View File
@@ -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",
+110
View File
@@ -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<Area | null>(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 (
<Dialog
open
onOpenChange={(next) => {
if (!next) onCancel();
}}
>
<DialogContent className="max-w-sm sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>{t("cropTitle")}</DialogTitle>
</DialogHeader>
<div className="relative h-72 w-full overflow-hidden rounded-lg bg-black">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={handleCropComplete}
/>
</div>
<div className="flex items-center gap-3 px-1">
<span className="shrink-0 text-xs text-muted-foreground">{t("zoom")}</span>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="w-full accent-orange-500"
aria-label={t("zoom")}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onCancel} disabled={isProcessing}>
{t("cropCancel")}
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={!cropAreaPixels || isProcessing}
>
{isProcessing ? t("cropSaving") : t("cropSave")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+71 -21
View File
@@ -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<string | null>(
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<HTMLInputElement>) => {
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}
</p>
)}
{pendingCrop && (
<AvatarCropDialog
imageSrc={pendingCrop.objectUrl}
mimeType={pendingCrop.file.type}
onCancel={closeCrop}
onCropped={handleCropped}
/>
)}
</div>
);
}
@@ -326,6 +368,14 @@ export function AvatarUpload({
{errorMessage}
</p>
)}
{pendingCrop && (
<AvatarCropDialog
imageSrc={pendingCrop.objectUrl}
mimeType={pendingCrop.file.type}
onCancel={closeCrop}
onCropped={handleCropped}
/>
)}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { Area } from "react-easy-crop";
function loadImage(url: string): Promise<HTMLImageElement> {
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<Blob> {
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
);
});
}