import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Cropper, { type Area } from 'react-easy-crop';
import { Modal } from './Modal';
interface Props {
/** When true, the dialog is mounted. Always pair with a `key` change on
* the source file so a fresh open re-runs the loader. */
open: boolean;
/** Source image. Object URL is created/revoked internally. */
file: File | null;
/** width:height ratio of the crop box. 1 for avatar, 3 for banner. */
aspect: number;
/** Output image dimensions in px. The crop area is scaled to this. */
outputWidth: number;
outputHeight: number;
title: string;
/** Called with the encoded blob when the user confirms. The dialog stays
* open until the parent flips `open` back off (typically after upload
* completes) — gives the parent a chance to show errors inline. */
onConfirm: (blob: Blob) => void;
onClose: () => void;
}
const ENCODE_QUALITY = 0.85;
// Crop dialog used by both avatar (1:1) and banner (3:1) flows. Wraps
// react-easy-crop in our standard modal chrome and runs the canvas crop
// inline on confirm so the upload helpers receive a ready-to-store Blob.
export function ImageCropDialog({
open,
file,
aspect,
outputWidth,
outputHeight,
title,
onConfirm,
onClose,
}: Props) {
const [crop, setCrop] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [areaPixels, setAreaPixels] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
// Resolve the file into an object URL exactly once per file. Revoked on
// unmount or when the file changes so a long crop session doesn't leak.
const objectUrl = useMemo(() => {
if (!file) return null;
return URL.createObjectURL(file);
}, [file]);
useEffect(() => {
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [objectUrl]);
// Reset transient UI state every time a new file is loaded so reopens
// don't carry over the previous session's zoom/offset.
useEffect(() => {
if (!file) return;
setCrop({ x: 0, y: 0 });
setZoom(1);
setAreaPixels(null);
setError(null);
}, [file]);
const onCropComplete = useCallback((_: Area, pixels: Area) => {
setAreaPixels(pixels);
}, []);
const submitting = useRef(false);
const handleConfirm = useCallback(async () => {
if (submitting.current || !objectUrl || !areaPixels) return;
submitting.current = true;
setBusy(true);
setError(null);
try {
const blob = await renderCrop({
srcUrl: objectUrl,
area: areaPixels,
outputWidth,
outputHeight,
});
onConfirm(blob);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'crop failed');
} finally {
submitting.current = false;
setBusy(false);
}
}, [objectUrl, areaPixels, outputWidth, outputHeight, onConfirm]);
return (
undefined : onClose} size="lg">
{/* Cropper canvas. Fixed height (320px) so the layout stays stable
regardless of the source image dimensions; react-easy-crop fits
the image into the box and the user pans/zooms inside. */}