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

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