Avatars/banners now resize to max 512px/1920px and convert to WebP before upload (zero server cost). Chat image uploads generate an 800px-wide WebP thumbnail via Sharp; the feed shows the thumbnail, click opens the full-res original. Adds lazy loading to avatars. Federation-compatible: remote instances without this feature fall back gracefully.
146 lines
4.9 KiB
TypeScript
146 lines
4.9 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 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>
|
|
);
|
|
}
|