'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. Ignored for PNG. */ outputQuality?: number; /** Output width in pixels (height derived from aspect). Default 512. */ outputWidth?: number; /** Output format. 'auto' (default) preserves alpha: PNG output when * the source is PNG/GIF/WebP/AVIF, JPEG otherwise. Override to force * a specific format. */ outputFormat?: 'auto' | 'jpeg' | 'png'; /** 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; } /** * MIME types that carry an alpha channel. A PNG source dropped through a * JPEG-output canvas composites transparent pixels against black on * export — destroying the design intent of any logo with transparency. * Default to PNG output whenever the source could have had alpha. */ const ALPHA_CAPABLE_MIME = new Set(['image/png', 'image/gif', 'image/webp', 'image/avif']); /** * 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, outputFormat = 'auto', 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 || !file) return; setUploading(true); try { const resolvedFormat: 'jpeg' | 'png' = outputFormat === 'auto' ? ALPHA_CAPABLE_MIME.has(file.type) ? 'png' : 'jpeg' : outputFormat; const blob = await renderCrop( objectUrl, pixels, aspect, outputWidth, outputQuality, resolvedFormat, ); 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 or PNG Blob. Async because * needs an onload event before we can drawImage. PNG preserves * alpha for logos; JPEG is smaller for photos / backgrounds. */ async function renderCrop( src: string, pixels: Area, aspect: number, outputWidth: number, quality: number, format: 'jpeg' | 'png', ): 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, ); const mime = format === 'png' ? 'image/png' : 'image/jpeg'; return await new Promise((resolve, reject) => { canvas.toBlob( (b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null'))), mime, // toBlob's quality param applies to JPEG/WebP only; PNG ignores it. format === 'jpeg' ? quality : undefined, ); }); } 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; }); }