feat: add image crop modal to space icon picker
Wire react-easy-crop into CreateSpace so users can crop and zoom before uploading a space icon. Adds reusable ImageCropModal component and canvas crop utility for future use in space settings and avatar editing.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import type { Area } from 'react-easy-crop';
|
||||
import { cropImage } from '../../utils/cropImage';
|
||||
|
||||
interface ImageCropModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
imageSrc: string;
|
||||
onCropComplete: (blob: Blob) => void;
|
||||
title?: string;
|
||||
aspectRatio?: number;
|
||||
cropShape?: 'round' | 'rect';
|
||||
}
|
||||
|
||||
export function ImageCropModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
imageSrc,
|
||||
onCropComplete,
|
||||
title = 'Crop Image',
|
||||
aspectRatio = 1,
|
||||
cropShape = 'round',
|
||||
}: ImageCropModalProps) {
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedPixels);
|
||||
}, []);
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!croppedAreaPixels) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const blob = await cropImage(imageSrc, croppedAreaPixels);
|
||||
onCropComplete(blob);
|
||||
onClose();
|
||||
} catch {
|
||||
// Silently fail — user can retry
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset state when opened with a new image
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setCroppedAreaPixels(null);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [isOpen, imageSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in">
|
||||
<div className="absolute inset-0 bg-surface-overlay" onClick={onClose} />
|
||||
<div className="relative max-w-md w-full mx-4 bg-surface-elevated rounded-lg shadow-xl animate-slide-up">
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center justify-between px-4 pt-4">
|
||||
<h2 className="text-xl font-bold text-txt-primary">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-txt-tertiary hover:text-txt-primary transition-colors p-1"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Crop area */}
|
||||
<div className="relative h-[350px] mx-4 mt-3 rounded-lg overflow-hidden bg-surface-base">
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={aspectRatio}
|
||||
cropShape={cropShape}
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={handleCropComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom slider */}
|
||||
<div className="px-4 pt-3 flex items-center gap-3">
|
||||
<svg className="w-4 h-4 text-txt-tertiary flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.05}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 h-1.5 rounded-full appearance-none bg-surface-input accent-accent-primary cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={isProcessing || !croppedAreaPixels}
|
||||
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isProcessing ? 'Applying...' : 'Apply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user