Files
dashboards/apps/business/src/components/ImageCropper.tsx
T
Alireza HassaniandCursor 468bb87fcf Improve business profile branding uploads and super-admin link menus.
Show separate light/dark logo and favicon croppers in a compact grid, support favicon saves, and keep super-admin overflow dashboard links in English.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 00:51:12 +03:30

181 lines
5.2 KiB
TypeScript

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
outputFormat?: 'jpeg' | 'png'
accept?: string
uploadLabel?: string
hint?: string
changeLabel?: string
/** Smaller upload/preview frame for narrow grid columns. */
compact?: boolean
}
export function ImageCropper({
value,
onChange,
aspect = 1,
outputFormat = 'jpeg',
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<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedArea, setCroppedArea] = useState<Area | null>(null)
useEffect(() => {
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedArea(null)
}, [aspect])
const onCropComplete = useCallback((_: Area, pixels: Area) => {
setCroppedArea(pixels)
}, [])
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
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 cropped = await getCroppedImage(imageSrc, croppedArea, outputFormat)
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',
}
return (
<div className={`${styles.wrapper} ${compact ? styles.compact : ''}`}>
{value && !imageSrc && (
<div className={styles.preview} style={frameStyle}>
<img src={value} alt={resolvedUploadLabel} className={styles.previewImg} />
<button
type="button"
className={styles.removeBtn}
onClick={removeThumbnail}
aria-label={t('products.form.thumbnailRemove')}
>
<X size={16} />
</button>
</div>
)}
{!value && !imageSrc && (
<label className={styles.uploadZone} style={frameStyle}>
<ImagePlus size={compact ? 20 : 28} />
<span>{resolvedUploadLabel}</span>
<span className={styles.hint}>{resolvedHint}</span>
<input type="file" accept={accept} onChange={handleFile} hidden />
</label>
)}
{imageSrc && (
<div className={styles.cropPanel}>
<div className={styles.cropArea} style={frameStyle}>
<Cropper
key={String(aspect)}
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={aspect}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
<div className={styles.cropControls}>
<label className={styles.zoomLabel}>
{t('products.form.zoom')}
<input
type="range"
min={1}
max={3}
step={0.05}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
/>
</label>
<div className={styles.cropActions}>
<button type="button" className={styles.cancelBtn} onClick={cancelCrop}>
{t('products.form.cancel')}
</button>
<button
type="button"
className={styles.applyBtn}
onClick={() => void applyCrop()}
disabled={!croppedArea}
>
{t('products.form.applyCrop')}
</button>
</div>
</div>
</div>
)}
{value && !imageSrc && (
<label className={styles.changeBtn}>
{resolvedChangeLabel}
<input type="file" accept={accept} onChange={handleFile} hidden />
</label>
)}
</div>
)
}