import { useCallback, useEffect, useState } from 'react' import Cropper, { type Area } from 'react-easy-crop' import { ImagePlus, X } from 'lucide-react' import { getCroppedImage } from '../utils/cropImage' import { useT } from '../i18n/useT' import styles from './ImageCropper.module.css' interface ImageCropperProps { value: string | null onChange: (value: string | null) => void /** Crop aspect ratio. Defaults to square (1). */ aspect?: number /** * Output format. When omitted, follows the uploaded file * (PNG stays PNG with transparent padding; other images become JPEG with white padding). */ outputFormat?: 'jpeg' | 'png' /** Fill color for JPEG and for crop areas outside the image. */ fillColor?: string /** Minimum zoom. Values below 1 shrink the image inside the crop frame. */ minZoom?: number maxZoom?: number accept?: string uploadLabel?: string hint?: string changeLabel?: string /** Smaller upload/preview frame for narrow grid columns. */ compact?: boolean } function isPngSource(file: File): boolean { return ( file.type === 'image/png' || file.name.toLowerCase().endsWith('.png') ) } function isPngDataUrl(src: string): boolean { return /^data:image\/png/i.test(src) } export function ImageCropper({ value, onChange, aspect = 1, outputFormat, fillColor, minZoom = 0.4, maxZoom = 3, accept = 'image/*', uploadLabel, hint, changeLabel, compact = false, }: ImageCropperProps) { const t = useT() const resolvedUploadLabel = uploadLabel ?? t('products.form.thumbnailUpload') const resolvedHint = hint ?? t('products.form.thumbnailHint') const resolvedChangeLabel = changeLabel ?? t('products.form.thumbnailChange') const [imageSrc, setImageSrc] = useState(null) const [sourceIsPng, setSourceIsPng] = useState(false) const [crop, setCrop] = useState({ x: 0, y: 0 }) const [zoom, setZoom] = useState(1) const [croppedArea, setCroppedArea] = useState(null) const resolvedFormat: 'jpeg' | 'png' = outputFormat ?? (sourceIsPng ? 'png' : 'jpeg') const resolvedFillColor = fillColor ?? (resolvedFormat === 'jpeg' ? '#ffffff' : undefined) const useTransparentPad = resolvedFormat === 'png' && !resolvedFillColor useEffect(() => { setCrop({ x: 0, y: 0 }) setZoom(1) setCroppedArea(null) }, [aspect]) const onCropComplete = useCallback((_: Area, pixels: Area) => { setCroppedArea(pixels) }, []) function handleFile(e: React.ChangeEvent) { const file = e.target.files?.[0] if (!file) return const png = isPngSource(file) setSourceIsPng(png) const reader = new FileReader() reader.onload = () => { setImageSrc(reader.result as string) setCrop({ x: 0, y: 0 }) setZoom(1) setCroppedArea(null) } reader.readAsDataURL(file) e.target.value = '' } async function applyCrop() { if (!imageSrc || !croppedArea) return const format: 'jpeg' | 'png' = outputFormat ?? (isPngDataUrl(imageSrc) || sourceIsPng ? 'png' : 'jpeg') const fill = fillColor ?? (format === 'jpeg' ? '#ffffff' : undefined) const cropped = await getCroppedImage(imageSrc, croppedArea, format, fill) onChange(cropped) setImageSrc(null) setZoom(1) setCrop({ x: 0, y: 0 }) } function cancelCrop() { setImageSrc(null) setZoom(1) setCrop({ x: 0, y: 0 }) } function removeThumbnail() { onChange(null) } const isPortrait = aspect < 1 const maxHeight = isPortrait ? 'min(360px, 42vh)' : 'min(280px, 36vh)' const frameStyle: React.CSSProperties = compact ? { aspectRatio: String(aspect), width: '100%', height: 'auto', minHeight: 0, } : { aspectRatio: String(aspect), width: `min(100%, calc(${maxHeight} * ${aspect}))`, height: 'auto', maxHeight, minHeight: 0, marginInline: 'auto', alignSelf: 'center', } const cropSurfaceStyle: React.CSSProperties = useTransparentPad ? { ...frameStyle } : { ...frameStyle, background: resolvedFillColor ?? '#1e293b' } return (
{value && !imageSrc && (
{resolvedUploadLabel}
)} {!value && !imageSrc && ( )} {imageSrc && (
= 1} style={{ containerStyle: useTransparentPad ? { background: 'transparent' } : { background: resolvedFillColor ?? '#1e293b' }, }} />
)} {value && !imageSrc && ( )}
) }