'use client'; import { useCallback, useState } from 'react'; import Cropper, { type Area } from 'react-easy-crop'; import { Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { toastError } from '@/lib/api/toast-error'; export interface ImageCropperDialogProps { open: boolean; onOpenChange: (open: boolean) => void; /** The picked file (raw, pre-crop). Cropper renders an object URL. */ file: File | null; /** Aspect ratio for the crop frame. 1 = square (avatars), 4 = wide * banners. Defaults to 1. */ aspect?: number; /** Output JPEG quality 0-1. Default 0.85. */ outputQuality?: number; /** Output width in pixels (height derived from aspect). Default 512. */ outputWidth?: number; /** Async upload handler — receives the cropped Blob. Cropper closes on * success; toasts on error. */ onUpload: (blob: Blob) => Promise; /** Dialog title. Default "Crop image". */ title?: string; } /** * Reusable crop-then-upload modal. Renders a draggable/zoomable crop * frame over the picked file, then writes the cropped pixels to a * Canvas, exports as JPEG, and hands the Blob to the caller. * * Used for: profile avatars, port logos, brochure covers — anywhere a * square or fixed-aspect image needs to land in storage. */ export function ImageCropperDialog({ open, onOpenChange, file, aspect = 1, outputQuality = 0.85, outputWidth = 512, onUpload, title = 'Crop image', }: ImageCropperDialogProps) { const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [pixels, setPixels] = useState(null); const [uploading, setUploading] = useState(false); const objectUrl = file ? URL.createObjectURL(file) : null; const onCropComplete = useCallback((_area: Area, areaPixels: Area) => { setPixels(areaPixels); }, []); async function handleUpload() { if (!objectUrl || !pixels) return; setUploading(true); try { const blob = await renderCrop(objectUrl, pixels, aspect, outputWidth, outputQuality); await onUpload(blob); onOpenChange(false); } catch (err) { toastError(err); } finally { setUploading(false); if (objectUrl) URL.revokeObjectURL(objectUrl); } } function handleClose(o: boolean) { if (!o && objectUrl) URL.revokeObjectURL(objectUrl); onOpenChange(o); } return ( {title} {objectUrl && ( <>
setZoom(Number(e.target.value))} className="w-full" />
)}
); } /** * Crop the source image at `pixels`, resize to `outputWidth`× * (outputWidth/aspect), and return as a JPEG Blob. Async because * needs an onload event before we can drawImage. */ async function renderCrop( src: string, pixels: Area, aspect: number, outputWidth: number, quality: number, ): Promise { const image = await loadImage(src); const canvas = document.createElement('canvas'); const outputHeight = Math.round(outputWidth / aspect); canvas.width = outputWidth; canvas.height = outputHeight; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas 2d context unavailable'); ctx.drawImage( image, pixels.x, pixels.y, pixels.width, pixels.height, 0, 0, outputWidth, outputHeight, ); return await new Promise((resolve, reject) => { canvas.toBlob( (b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null'))), 'image/jpeg', quality, ); }); } function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = (e) => reject(e); img.src = src; }); }