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:
1 parent
5fbf39593d
commit
6fd5f2dff8
5 files changed
+256
-21
No files matched your search
Generated
+20
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user