From 1068e5beb03820eafbe60fa1e6ed0951a3ceb207 Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Sat, 11 Oct 2025 08:25:34 +0000 Subject: [PATCH] Add an image editing tool for applying enhancements and overlays Integrates a new image editor page with routing, sidebar navigation, and Cloudinary transformation URL generation for applying various filters, ribbons, price badges, and logos to selected media. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/dE5lg1s --- .replit | 4 + client/src/App.tsx | 2 + client/src/components/sidebar.tsx | 3 +- client/src/pages/image-editor.tsx | 519 ++++++++++++++++++++++++++++++ 4 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 client/src/pages/image-editor.tsx diff --git a/.replit b/.replit index 164cd5a..08da511 100644 --- a/.replit +++ b/.replit @@ -34,6 +34,10 @@ externalPort = 6800 localPort = 37431 externalPort = 5000 +[[ports]] +localPort = 37567 +externalPort = 9000 + [[ports]] localPort = 37659 externalPort = 4200 diff --git a/client/src/App.tsx b/client/src/App.tsx index 7fd12f2..52d1fc6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -10,6 +10,7 @@ import Dashboard from "@/pages/dashboard"; import NewPost from "@/pages/new-post"; import Calendar from "@/pages/calendar"; import Media from "@/pages/media"; +import ImageEditor from "@/pages/image-editor"; import PagesManagement from "@/pages/pages"; import AI from "@/pages/ai"; import History from "@/pages/history"; @@ -68,6 +69,7 @@ function Router() { {() => } {() => } {() => } + {() => } {() => } {() => } {() => } diff --git a/client/src/components/sidebar.tsx b/client/src/components/sidebar.tsx index 3e6e0a2..e8870e5 100644 --- a/client/src/components/sidebar.tsx +++ b/client/src/components/sidebar.tsx @@ -1,4 +1,4 @@ -import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight } from "lucide-react"; +import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, Wand2 } from "lucide-react"; import { Link, useLocation } from "wouter"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiRequest, queryClient } from "@/lib/queryClient"; @@ -63,6 +63,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) { { icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null }, { icon: Calendar, label: "Calendrier", href: "/calendar", badge: null }, { icon: Images, label: "Médiathèque", href: "/media", badge: null }, + { icon: Wand2, label: "Éditeur d'images", href: "/image-editor", badge: null }, ]; const statsItems = [ diff --git a/client/src/pages/image-editor.tsx b/client/src/pages/image-editor.tsx new file mode 100644 index 0000000..f84dfb0 --- /dev/null +++ b/client/src/pages/image-editor.tsx @@ -0,0 +1,519 @@ +import { useState, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +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"; + +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"; +} + +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" + }); + + 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"], + }); + + + // 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}`); + } + + // Ribbon overlay (rectangle in corner) + if (ribbon.enabled) { + const ribbonColor = ribbon.color === "red" ? "FF0000" : "FFC107"; + // URL encode the text to handle accents and special characters + const ribbonText = encodeURIComponent(ribbon.text); + + transformations.push( + // Create rectangle ribbon with padding, positioned in corner + `l_text:Arial_40_bold: ${ribbonText} ,co_white,b_rgb:${ribbonColor}`, + `fl_layer_apply,g_${ribbon.position},x_10,y_10` + ); + } + + // Price badge overlay (burst/starburst effect with large background) + if (priceBadge.enabled && priceBadge.price) { + const badgeColor = priceBadge.color === "red" ? "DC2626" : "FFC107"; + // URL encode the price text + const priceText = encodeURIComponent(`€${priceBadge.price}`); + + // Create burst/starburst badge effect with layered circles + transformations.push( + // Outer burst circle (largest) + `l_text:Arial_120_bold:★,co_rgb:${badgeColor}`, + `fl_layer_apply,g_${priceBadge.position},x_40,y_40`, + // Inner circle for depth + `l_text:Arial_90_bold:●,co_rgb:${badgeColor}`, + `fl_layer_apply,g_${priceBadge.position},x_40,y_40`, + // Price text on top (smaller) + `l_text:Arial_32_bold:${priceText},co_white`, + `fl_layer_apply,g_${priceBadge.position},x_40,y_40` + ); + } + + // 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 })} + > +
+ + +
+
+ + +
+
+
+ +
+ + +
+ + )} +
+
+ + {/* 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 + +
+ +
+ + +
+
+ ) : ( +
+

Sélectionnez une image

+
+ )} +
+
+
+
+
+
+
+ ); +}