veza/apps/web/src/components/ui/ImageCropper.tsx
senke 1e897c95a0 ui(tokens): migrate kodo-cyan to primary (51 files, 88 instances)
Replace legacy text-kodo-cyan/border-kodo-cyan/bg-kodo-cyan with semantic
text-primary/border-primary/bg-primary across 51 components.

The brand primary color now uses the design system token, enabling proper
theme adaptation. Covers UI primitives, search, dashboard, chat, playlists,
settings, social, marketplace, and auth components.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 00:19:12 +01:00

211 lines
6.4 KiB
TypeScript

import React, { useState, useCallback, lazy, Suspense } from 'react';
// PERF: Lazy load react-easy-crop (composant volumineux ~100KB)
// Mock Cropper since react-easy-crop is missing
const Cropper = lazy(() =>
Promise.resolve({
default: (_props: any) => (
<div className="bg-kodo-graphite flex items-center justify-center h-full text-white">
Cropper Mock
</div>
),
}),
);
import { Button } from '@/components/ui/button';
import { X, ZoomIn, RotateCw, Check } from 'lucide-react';
import { LoadingSpinner } from './loading-spinner';
/**
* ImageCropperProps - Propriétés du composant ImageCropper
*
* @interface ImageCropperProps
*/
interface ImageCropperProps {
/**
* URL ou source de l'image à recadrer
*/
imageSrc: string;
/**
* Ratio d'aspect du recadrage
*
* - `1`: Ratio carré (pour avatars)
* - `3`: Ratio paysage (pour bannières)
*
* @example
* ```tsx
* <ImageCropper imageSrc={image} aspectRatio={1} />
* ```
*/
aspectRatio: number;
/**
* Fonction appelée pour annuler le recadrage
*/
onCancel: () => void;
/**
* Fonction appelée lorsque le recadrage est terminé
*
* @param {any} croppedAreaPixels - Zone recadrée en pixels
*/
onCropComplete: (croppedAreaPixels: any) => void;
/**
* Si `true`, utilise un recadrage circulaire (pour avatars)
*
* @default false
*/
circularCrop?: boolean;
}
/**
* ImageCropper - Composant de recadrage d'image avec design system Kodo
*
* Composant modal pour recadrer des images avec support pour :
* - Zoom (1x à 3x)
* - Rotation (0° à 360°)
* - Recadrage circulaire ou rectangulaire
* - Ratio d'aspect personnalisable
*
* @example
* ```tsx
* // Recadrage d'avatar (carré, circulaire)
* <ImageCropper
* imageSrc={avatarSrc}
* aspectRatio={1}
* circularCrop={true}
* onCancel={() => setShowCropper(false)}
* onCropComplete={(area) => handleCrop(area)}
* />
* ```
*
* @example
* ```tsx
* // Recadrage de bannière (paysage, rectangulaire)
* <ImageCropper
* imageSrc={bannerSrc}
* aspectRatio={3}
* circularCrop={false}
* onCancel={() => setShowCropper(false)}
* onCropComplete={(area) => handleCrop(area)}
* />
* ```
*
* @component
* @param {ImageCropperProps} props - Propriétés du composant
* @returns {JSX.Element} Modal de recadrage avec contrôles
*/
export const ImageCropper: React.FC<ImageCropperProps> = ({
imageSrc,
aspectRatio,
onCancel,
onCropComplete,
circularCrop = false,
}) => {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
const onCropChange = (crop: { x: number; y: number }) => {
setCrop(crop);
};
const onCropCompleteHandler = useCallback(
(_croppedArea: any, croppedAreaPixels: any) => {
setCroppedAreaPixels(croppedAreaPixels);
},
[],
);
const handleSave = () => {
onCropComplete(croppedAreaPixels);
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-kodo-void/95 backdrop-blur-sm">
<div className="relative w-full max-w-2xl bg-kodo-graphite border border-border rounded-xl shadow-2xl overflow-hidden flex flex-col h-[80vh]">
{/* Header */}
<div className="p-4 border-b border-border flex justify-between items-center bg-kodo-ink">
<h3 className="font-bold text-white flex items-center gap-2">
Edit Image
</h3>
<Button variant="ghost" size="icon" onClick={onCancel}>
<X className="w-5 h-5" />
</Button>
</div>
{/* Cropper Area */}
<div className="relative flex-1 bg-black">
<Suspense
fallback={
<div className="w-full h-full flex items-center justify-center">
<LoadingSpinner size="lg" text="Chargement du recadreur..." />
</div>
}
>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
rotation={rotation}
aspect={aspectRatio}
onCropChange={onCropChange}
onCropComplete={onCropCompleteHandler}
onZoomChange={setZoom}
cropShape={circularCrop ? 'round' : 'rect'}
showGrid={true}
/>
</Suspense>
</div>
{/* Controls */}
<div className="p-8 bg-kodo-ink border-t border-border space-y-4">
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground w-16">Zoom</span>
<ZoomIn className="w-4 h-4 text-muted-foreground" />
<input
type="range"
value={zoom}
min={1}
max={3}
step={0.1}
aria-labelledby="Zoom"
onChange={(e) => setZoom(Number(e.target.value))}
className="flex-1 h-1 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:rounded-full"
/>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground w-16">Rotate</span>
<RotateCw className="w-4 h-4 text-muted-foreground" />
<input
type="range"
value={rotation}
min={0}
max={360}
step={1}
aria-labelledby="Rotation"
onChange={(e) => setRotation(Number(e.target.value))}
className="flex-1 h-1 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:rounded-full"
/>
</div>
<div className="flex justify-end gap-4 pt-2">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
icon={<Check className="w-4 h-4" />}
>
Apply Crop
</Button>
</div>
</div>
</div>
</div>
);
};