Files
backspace/packages/web/src/components/ui/ImageCropModal.tsx
T
Jannis Braun a07f2314ed fix(web): standardize modal button layout across all dialog modals
Apply symmetrical full-width button pattern to:
- Delete Identity dialog (ConnectedInstances)
- Transfer Ownership confirmation
- Image Crop modal
2026-04-03 01:15:14 +02:00

146 lines
5.0 KiB
TypeScript

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';
maxOutputDimension?: number;
outputType?: string;
}
export function ImageCropModal({
isOpen,
onClose,
imageSrc,
onCropComplete,
title = 'Crop Image',
aspectRatio = 1,
cropShape = 'round',
maxOutputDimension,
outputType,
}: 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, outputType ?? 'image/webp', {
maxDimension: maxOutputDimension,
outputType,
});
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 gap-3 p-4">
<button
type="button"
onClick={onClose}
className="flex-1 py-2.5 text-sm font-medium text-txt-secondary bg-interactive-hover hover:bg-interactive-selected rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleApply}
disabled={isProcessing || !croppedAreaPixels}
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{isProcessing ? 'Applying...' : 'Apply'}
</button>
</div>
</div>
</div>
);
}