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>
181 lines
5.2 KiB
TypeScript
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>
|
|
)
|
|
}
|