import { useState, useEffect, useRef } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { toPng } from 'html-to-image'; import Sidebar from "@/components/sidebar"; import TopBar from "@/components/topbar"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { Badge } from "@/components/ui/badge"; import { Wand2, Save, ArrowRight, Image as ImageIcon, X } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; interface Ribbon { enabled: boolean; text: "Promo" | "Nouveauté" | "Arrivage"; color: "red" | "yellow"; position: "north_west" | "north_east"; } interface PriceBadge { enabled: boolean; price: string; color: "red" | "yellow"; position: "north_east" | "south_east" | "north_west" | "south_west"; size: number; // Font size in pixels } interface Logo { enabled: boolean; opacity: number; position: "north_east" | "south_east" | "north_west" | "south_west" | "center"; } interface Filters { brightness: number; contrast: number; saturation: number; blur: number; sharpen: number; effect: "none" | "grayscale" | "sepia" | "vintage"; } export default function ImageEditor() { const [sidebarOpen, setSidebarOpen] = useState(false); const [selectedMedia, setSelectedMedia] = useState(null); const [ribbon, setRibbon] = useState({ enabled: false, text: "Promo", color: "red", position: "north_west" }); const [priceBadge, setPriceBadge] = useState({ enabled: false, price: "", color: "red", position: "north_east", size: 20 // Default font size }); const [logo, setLogo] = useState({ enabled: false, opacity: 60, position: "south_east" }); const [filters, setFilters] = useState({ brightness: 0, contrast: 0, saturation: 0, blur: 0, sharpen: 0, effect: "none" }); const [previewUrl, setPreviewUrl] = useState(""); const { data: mediaList } = useQuery({ queryKey: ["/api/media"], }); const previewRef = useRef(null); const { toast } = useToast(); // State for generated ribbon overlay const [ribbonPublicId, setRibbonPublicId] = useState(null); const [isGeneratingRibbon, setIsGeneratingRibbon] = useState(false); // Save edited image mutation - DOM capture with html-to-image const saveImageMutation = useMutation({ mutationFn: async () => { if (!selectedMedia || !previewRef.current) { throw new Error("Aucune image sélectionnée"); } // Get the preview image element to capture its dimensions const previewImg = previewRef.current.querySelector('img'); if (!previewImg) { throw new Error("Image de preview non trouvée"); } // Load original image to get natural dimensions const originalImg = new Image(); originalImg.crossOrigin = 'anonymous'; originalImg.src = selectedMedia.originalUrl; await new Promise((resolve, reject) => { originalImg.onload = resolve; originalImg.onerror = reject; }); const naturalWidth = originalImg.naturalWidth; const naturalHeight = originalImg.naturalHeight; const displayWidth = previewImg.clientWidth; const displayHeight = previewImg.clientHeight; // Calculate scale factor const scale = naturalWidth / displayWidth; // Capture preview as data URL using html-to-image at high resolution const dataUrl = await toPng(previewRef.current, { quality: 1, pixelRatio: scale, // Render at natural image resolution canvasWidth: naturalWidth, canvasHeight: naturalHeight, skipAutoScale: true }); // Convert data URL to blob const response = await fetch(dataUrl); const blob = await response.blob(); // Upload const formData = new FormData(); formData.append('file', blob, `edited_${Date.now()}.jpg`); const uploadResponse = await fetch('/api/media/upload', { method: 'POST', body: formData, credentials: 'include' }); if (!uploadResponse.ok) { throw new Error("Erreur lors de l'upload"); } return uploadResponse.json(); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/media"] }); toast({ title: "Image enregistrée !", description: "L'image éditée a été ajoutée à votre médiathèque.", }); }, onError: (error: Error) => { toast({ title: "Erreur", description: error.message || "Impossible d'enregistrer l'image.", variant: "destructive" }); } }); // Generate ribbon when settings change useEffect(() => { if (!ribbon.enabled || !ribbon.text) { setRibbonPublicId(null); return; } const generateRibbon = async () => { setIsGeneratingRibbon(true); try { const response = await apiRequest("POST", "/api/media/generate-ribbon", { text: ribbon.text, color: ribbon.color, position: ribbon.position }); const data = await response.json(); setRibbonPublicId(data.publicId); } catch (error) { console.error("Error generating ribbon:", error); } finally { setIsGeneratingRibbon(false); } }; // Debounce to avoid too many requests const timer = setTimeout(generateRibbon, 500); return () => clearTimeout(timer); }, [ribbon.enabled, ribbon.text, ribbon.color, ribbon.position]); // Generate Cloudinary transformation URL useEffect(() => { if (!selectedMedia) { setPreviewUrl(""); return; } // Start with base image URL let url = selectedMedia.originalUrl; // Extract Cloudinary parts const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/image\/upload\//; const match = url.match(cloudinaryPattern); if (!match) { setPreviewUrl(url); return; } const [fullMatch, cloudName] = match; const imagePath = url.replace(fullMatch, ''); // Build transformations array const transformations: string[] = []; // Base resize transformations.push('w_800,h_800,c_fit'); // Filters if (filters.brightness !== 0) { transformations.push(`e_brightness:${filters.brightness}`); } if (filters.contrast !== 0) { transformations.push(`e_contrast:${filters.contrast}`); } if (filters.saturation !== 0) { transformations.push(`e_saturation:${filters.saturation}`); } if (filters.blur > 0) { transformations.push(`e_blur:${filters.blur * 100}`); } if (filters.sharpen > 0) { transformations.push(`e_sharpen:${filters.sharpen * 100}`); } if (filters.effect !== "none") { transformations.push(`e_${filters.effect}`); } // Note: Ribbon and price badge overlays are rendered as CSS elements in preview // They are NOT added to Cloudinary transformation URL // This allows for better visual control and positioning // Logo overlay - using uploaded logo if available if (logo.enabled) { // TODO: Implement logo upload/selection system // For now, use a watermark text transformations.push( `l_text:Arial_32:© LOGO,co_white,o_${logo.opacity}`, `fl_layer_apply,g_${logo.position},x_30,y_30` ); } // Construct final URL const transformedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${transformations.join('/')}/${imagePath}`; setPreviewUrl(transformedUrl); }, [selectedMedia, ribbon, priceBadge, logo, filters]); return (
{sidebarOpen && (
setSidebarOpen(false)} /> )}
setSidebarOpen(false)} />
setSidebarOpen(!sidebarOpen)} />

Éditeur d'images

Ajoutez des rubans promotionnels, badges prix et filtres à vos images

{/* Left Column - Controls */}
{/* Image Selection */} Sélectionner une image {mediaList && (mediaList as any[]).filter((m: any) => m.type === 'image').length > 0 ? (
{(mediaList as any[]) .filter((m: any) => m.type === 'image') .map((media: any) => (
setSelectedMedia(media)} data-testid={`select-image-${media.id}`} >
))}
) : (

Aucune image disponible. Uploadez des images dans la médiathèque.

)}
{/* Ribbon Controls */} Ruban promotionnel
setRibbon({ ...ribbon, enabled: checked as boolean })} data-testid="checkbox-ribbon" />
{ribbon.enabled && ( <>
setRibbon({ ...ribbon, text: value as any })} >
setRibbon({ ...ribbon, color: value as any })} >
)}
{/* Price Badge */} Badge prix
setPriceBadge({ ...priceBadge, enabled: checked as boolean })} data-testid="checkbox-price" />
{priceBadge.enabled && ( <>
setPriceBadge({ ...priceBadge, price: e.target.value })} data-testid="input-price" />
setPriceBadge({ ...priceBadge, color: value as any })} >
{priceBadge.size}px
setPriceBadge({ ...priceBadge, size: value[0] })} min={12} max={40} step={1} data-testid="slider-price-size" />
)}
{/* Filters */} Filtres
{filters.brightness}
setFilters({ ...filters, brightness: value })} min={-50} max={50} step={5} data-testid="slider-brightness" />
{filters.contrast}
setFilters({ ...filters, contrast: value })} min={-50} max={50} step={5} data-testid="slider-contrast" />
{filters.saturation}
setFilters({ ...filters, saturation: value })} min={-100} max={100} step={10} data-testid="slider-saturation" />
{/* Right Column - Preview */}
Aperçu {selectedMedia ? (
Aperçu {/* CSS Triangle Ribbon Overlay - Auto-centered */} {ribbon.enabled && ribbon.text && (
{ribbon.text}
)} {/* Price Badge Overlay (CSS) */} {priceBadge.enabled && priceBadge.price && (
€{priceBadge.price}
)}
) : (

Sélectionnez une image

)}
); }