veza/apps/web/src/components/ui/ImageCropper.tsx
senke 8e9ee2f3a5 fix: stabilize builds, tests, and lint across all stacks
Complete stabilization pass bringing all 3 stacks to green:

Frontend (apps/web/):
- Fix TypeScript nullability in useSeason.ts, useTimeOfDay.ts hooks
- Disable no-undef in ESLint config (TypeScript handles it; JSX misidentified)
- Rename 306 story imports from @storybook/react to @storybook/react-vite
- Fix conditional hook call in useMediaQuery.ts useIsTablet
- Move useQuery to top of LoginPage.tsx component
- Remove useless try/catch in GearFormModal.tsx
- Fix stale closure in ResetPasswordPage.tsx handleChange
- Make Storybook decorators (withRouter, withQueryClient, withToast, withAudio)
  no-ops since global StorybookDecorator already provides these — prevents
  nested Router / duplicate provider crashes in vitest-browser
- Fix nested MemoryRouter in 3 page stories (TrackDetail, PlaylistDetail, UserProfile)
- Update i18n initialization in test setup (await init before changeLanguage)
- Update ~30 test assertions from English to French to match i18n translations
- Update test assertions to match SUMI V3 design changes (shadow vs border)
- Fix remaining story type errors (PlayerError, PlaylistBatchActions,
  TrackFilters, VirtualizedChatMessages)

Backend (veza-backend-api/):
- Fix response_test.go RespondWithAppError signature (2 args, not 3)
- Fix TestErrorContractAuthEndpoints expected error codes
  (ErrCodeUnauthorized vs ErrCodeInvalidCredentials)
- Fix TestTrackHandler_GetTrackLikes_Success missing auth middleware setup
- Fix TestPlaybackAnalyticsService_GetTrackStats k-anonymity threshold
  (needs 5 unique users, not 1)
- Replace NOW() PostgreSQL function with time.Now() parameter in marketplace
  service for SQLite test compatibility
- Add missing AutoMigrate entries in marketplace_test.go
  (ProductImage, ProductPreview, ProductLicense, ProductReview)

Results:
- Frontend TypeCheck: 617 errors -> 0 errors
- Frontend ESLint: 349 errors -> 0 errors
- Frontend Vitest: 196 failing tests -> 1 skipped (3396/3397 passing)
- Backend go vet: 1 error -> 0 errors
- Backend tests: 5 failing -> all 13 packages passing
- Rust: 150/150 tests passing (unchanged)
- Storybook audit: 0 errors across 1244 stories

Triage report: docs/TRIAGE_REPORT.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:48:07 +02: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-muted flex items-center justify-center h-full text-foreground">
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-[var(--sumi-z-modal)] flex items-center justify-center p-4 bg-background/95 backdrop-blur-sm">
<div className="relative w-full max-w-2xl bg-muted rounded-xl shadow-2xl overflow-hidden flex flex-col h-layout-modal-sm">
{/* Header */}
<div className="p-4 shadow-[0_1px_0_var(--border)] flex justify-between items-center bg-card">
<h3 className="font-bold text-foreground 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-card shadow-[0_-1px_0_var(--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>
);
};