diff --git a/attached_assets/Logo_1763453712905.png b/attached_assets/Logo_1763453712905.png new file mode 100644 index 0000000..bdbca3d Binary files /dev/null and b/attached_assets/Logo_1763453712905.png differ diff --git a/client/src/pages/image-editor.tsx b/client/src/pages/image-editor.tsx index ef76dc7..85fbdc6 100644 --- a/client/src/pages/image-editor.tsx +++ b/client/src/pages/image-editor.tsx @@ -35,19 +35,7 @@ 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; - vibrance: number; - hue: number; - gamma: number; - vignette: number; - effect: "none" | "grayscale" | "sepia" | "vintage" | "incognito" | "athena" | "audrey"; + size: "small" | "medium" | "large"; } export default function ImageEditor() { @@ -72,20 +60,8 @@ export default function ImageEditor() { 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, - vibrance: 0, - hue: 0, - gamma: 0, - vignette: 0, - effect: "none" + position: "south_east", + size: "medium" }); const [previewUrl, setPreviewUrl] = useState(""); @@ -94,58 +70,30 @@ export default function ImageEditor() { queryKey: ["/api/media"], }); + const { data: cloudinaryConfig } = useQuery({ + queryKey: ['/api/cloudinary/config'], + }); + const previewRef = useRef(null); const { toast } = useToast(); - // Save edited image mutation - DOM capture with html-to-image + // Save edited image mutation - Server-side processing with Sharp const saveImageMutation = useMutation({ mutationFn: async () => { - if (!selectedMedia || !previewRef.current) { + if (!selectedMedia) { throw new Error("Aucune image sélectionnée"); } - // Capture preview exactly as displayed (no scaling) - const dataUrl = await toPng(previewRef.current, { - quality: 0.95, - pixelRatio: 1, // Keep 1:1 - no zoom - }); + // Send image URL and overlay parameters to backend + const payload = { + imageUrl: selectedMedia.originalUrl, + ribbon: ribbon.enabled ? ribbon : undefined, + priceBadge: priceBadge.enabled && priceBadge.price ? priceBadge : undefined, + logo: logo.enabled ? logo : undefined, + }; - // Convert data URL to blob - const response = await fetch(dataUrl); - let blob = await response.blob(); - - // Compress if needed to stay under Cloudinary limit - if (blob.size > 8 * 1024 * 1024) { - const img = new Image(); - img.src = dataUrl; - await new Promise((resolve) => { img.onload = resolve; }); - - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d')!; - ctx.drawImage(img, 0, 0); - - blob = await new Promise((resolve) => { - canvas.toBlob((b) => resolve(b!), 'image/jpeg', 0.8); - }); - } - - // 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(); + const response = await apiRequest('POST', '/api/media/apply-overlays', payload); + return response; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/media"] }); @@ -192,80 +140,18 @@ export default function ImageEditor() { // 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.vibrance !== 0) { - transformations.push(`e_vibrance:${filters.vibrance}`); - } - if (filters.hue !== 0) { - transformations.push(`e_hue:${filters.hue}`); - } - if (filters.gamma !== 0) { - transformations.push(`e_gamma:${filters.gamma}`); - } - if (filters.vignette > 0) { - transformations.push(`e_vignette:${filters.vignette}`); - } - if (filters.effect !== "none") { - // Map effects to proper Cloudinary transformations - switch(filters.effect) { - case "grayscale": - transformations.push("e_grayscale"); - break; - case "sepia": - transformations.push("e_sepia"); - break; - case "vintage": - // Vintage effect: combine sepia + vignette + reduced saturation - transformations.push("e_sepia:50"); - transformations.push("e_vignette:30"); - transformations.push("e_saturation:-20"); - break; - case "incognito": - transformations.push("e_art:incognito"); - break; - case "athena": - transformations.push("e_art:athena"); - break; - case "audrey": - transformations.push("e_art:audrey"); - break; - } - } - // 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` - ); - } + // Logo overlay will be added by backend when saving + // Preview just shows the base image with CSS overlays // Construct final URL const transformedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${transformations.join('/')}/${imagePath}`; setPreviewUrl(transformedUrl); - }, [selectedMedia, ribbon, priceBadge, logo, filters]); + }, [selectedMedia, ribbon, priceBadge, logo]); return (
@@ -596,134 +482,72 @@ export default function ImageEditor() { - {/* Filters */} + {/* Logo */} - Filtres + Logo - -
-
- - {filters.brightness} -
- setFilters({ ...filters, brightness: value })} - min={-50} - max={50} - step={5} - data-testid="slider-brightness" + +
+ setLogo({ ...logo, enabled: checked as boolean })} + data-testid="checkbox-logo-enabled" /> +
-
-
- - {filters.contrast} -
- setFilters({ ...filters, contrast: value })} - min={-50} - max={50} - step={5} - data-testid="slider-contrast" - /> -
+ {logo.enabled && ( + <> +
+ + +
-
-
- - {filters.saturation} -
- setFilters({ ...filters, saturation: value })} - min={-100} - max={100} - step={10} - data-testid="slider-saturation" - /> -
+
+ + +
-
-
- - {filters.vibrance} -
- setFilters({ ...filters, vibrance: value })} - min={-100} - max={100} - step={10} - data-testid="slider-vibrance" - /> -
- -
-
- - {filters.hue}° -
- setFilters({ ...filters, hue: value })} - min={-180} - max={180} - step={10} - data-testid="slider-hue" - /> -
- -
-
- - {filters.gamma} -
- setFilters({ ...filters, gamma: value })} - min={-50} - max={150} - step={10} - data-testid="slider-gamma" - /> -
- -
-
- - {filters.vignette} -
- setFilters({ ...filters, vignette: value })} - min={0} - max={100} - step={10} - data-testid="slider-vignette" - /> -
- -
- - -
+
+
+ + {logo.opacity}% +
+ setLogo({ ...logo, opacity: value[0] })} + min={0} + max={100} + step={5} + data-testid="slider-logo-opacity" + /> +
+ + )}
@@ -794,6 +618,26 @@ export default function ImageEditor() { {priceBadge.price}€
)} + + {/* Logo Overlay (CSS) */} + {logo.enabled && cloudinaryConfig && (cloudinaryConfig as any).logoPublicId && ( + Logo + )} + +
+ + { + handleLogoChange(e); + if (e.target.files?.[0]) { + uploadLogoMutation.mutate(e.target.files[0]); + } + }} + /> +
+ + ) : ( +
+
+ +

+ Aucun logo uploadé. Choisissez une image PNG ou JPG. +

+ + { + handleLogoChange(e); + if (e.target.files?.[0]) { + uploadLogoMutation.mutate(e.target.files[0]); + } + }} + /> +
+ {!cloudinaryConfig && ( +

+ Veuillez d'abord configurer Cloudinary ci-dessus +

+ )} +
+ )} + + + diff --git a/server/routes.ts b/server/routes.ts index 3e44843..6b7a4b9 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -521,7 +521,7 @@ export async function registerRoutes(app: Express): Promise { // IMPORTANT: This must be BEFORE /api/media/:id to avoid being intercepted by :id parameter app.post("/api/media/apply-overlays", requireAuth, async (req, res) => { try { - const { imageUrl, ribbon, priceBadge } = req.body; + const { imageUrl, ribbon, priceBadge, logo } = req.body; const user = req.user as User; const userId = user.id; @@ -533,6 +533,7 @@ export async function registerRoutes(app: Express): Promise { console.log("📥 Image URL:", imageUrl); console.log("🎀 Ribbon:", ribbon); console.log("💰 Price Badge:", priceBadge); + console.log("🏢 Logo:", logo); // Download image from URL const imageResponse = await fetch(imageUrl); @@ -657,6 +658,86 @@ export async function registerRoutes(app: Express): Promise { }); } + // Add logo overlay + if (logo && logo.enabled) { + console.log("🏢 Adding logo overlay..."); + + // Get Cloudinary config to get logo public ID + const cloudinaryConfig = await storage.getAnyCloudinaryConfig(); + if (cloudinaryConfig && cloudinaryConfig.logoPublicId) { + try { + // Build logo URL from Cloudinary + const logoUrl = `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`; + console.log("📥 Downloading logo from:", logoUrl); + + // Download logo + const logoResponse = await fetch(logoUrl); + const logoBuffer = Buffer.from(await logoResponse.arrayBuffer()); + + // Determine logo size based on selection + const logoSizes = { + small: Math.round(width * 0.15), // 15% of image width + medium: Math.round(width * 0.25), // 25% of image width + large: Math.round(width * 0.35) // 35% of image width + }; + const logoWidth = logoSizes[logo.size as keyof typeof logoSizes] || logoSizes.medium; + + // Resize logo preserving aspect ratio and apply opacity + const resizedLogo = await sharp(logoBuffer) + .resize({ width: logoWidth, fit: 'contain' }) + .ensureAlpha() + .toBuffer(); + + // Get resized logo dimensions + const logoMetadata = await sharp(resizedLogo).metadata(); + const logoHeight = logoMetadata.height || logoWidth; + + // Calculate position with padding + const padding = 20; + let logoX = padding; + let logoY = padding; + + if (logo.position === 'north_east') { + logoX = width - logoWidth - padding; + } else if (logo.position === 'south_west') { + logoY = height - logoHeight - padding; + } else if (logo.position === 'south_east') { + logoX = width - logoWidth - padding; + logoY = height - logoHeight - padding; + } else if (logo.position === 'center') { + logoX = Math.round((width - logoWidth) / 2); + logoY = Math.round((height - logoHeight) / 2); + } + + // Apply opacity by manipulating alpha channel + let logoWithOpacity = resizedLogo; + if (logo.opacity < 100) { + // Create a semi-transparent version of the logo + const opacityFactor = logo.opacity / 100; + logoWithOpacity = await sharp(resizedLogo) + .ensureAlpha() + .linear(opacityFactor, 0) + .toBuffer(); + } + + // Add logo overlay + overlays.push({ + input: logoWithOpacity, + top: logoY, + left: logoX, + blend: 'over' + }); + + console.log(`✅ Logo added at position ${logo.position} with ${logo.opacity}% opacity`); + } catch (logoError) { + console.error("⚠️ Failed to apply logo:", logoError); + // Continue without logo if it fails + } + } else { + console.log("⚠️ No logo configured in settings"); + } + } + // Apply all overlays if (overlays.length > 0) { image = image.composite(overlays); @@ -1295,6 +1376,83 @@ export async function registerRoutes(app: Express): Promise { } }); + // Upload company logo + app.post("/api/cloudinary/logo", requireAdmin, upload.single("logo"), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: "No logo file uploaded" }); + } + + const user = req.user as User; + const userId = user.id; + + // Check if Cloudinary is configured + const cloudinaryConfig = await storage.getCloudinaryConfig(userId); + if (!cloudinaryConfig) { + return res.status(400).json({ + error: "Cloudinary not configured. Please configure Cloudinary first." + }); + } + + // Delete old logo if exists + if (cloudinaryConfig.logoPublicId) { + try { + await cloudinaryService.deleteLogo(cloudinaryConfig.logoPublicId); + } catch (error) { + console.warn("Failed to delete old logo:", error); + // Continue anyway - not critical + } + } + + // Upload new logo to Cloudinary + const uploadResult = await cloudinaryService.uploadLogo( + req.file.buffer, + req.file.originalname + ); + + // Update config with new logo public ID + const updatedConfig = await storage.updateCloudinaryConfig(userId, { + logoPublicId: uploadResult.publicId, + }); + + res.json({ + logoPublicId: uploadResult.publicId, + logoUrl: uploadResult.url, + }); + } catch (error) { + console.error("Error uploading logo:", error); + const errorMessage = error instanceof Error ? error.message : "Failed to upload logo"; + res.status(500).json({ error: errorMessage }); + } + }); + + // Delete company logo + app.delete("/api/cloudinary/logo", requireAdmin, async (req, res) => { + try { + const user = req.user as User; + const userId = user.id; + + const cloudinaryConfig = await storage.getCloudinaryConfig(userId); + if (!cloudinaryConfig || !cloudinaryConfig.logoPublicId) { + return res.status(404).json({ error: "No logo found" }); + } + + // Delete from Cloudinary + await cloudinaryService.deleteLogo(cloudinaryConfig.logoPublicId); + + // Remove from config + await storage.updateCloudinaryConfig(userId, { + logoPublicId: null, + }); + + res.json({ success: true }); + } catch (error) { + console.error("Error deleting logo:", error); + const errorMessage = error instanceof Error ? error.message : "Failed to delete logo"; + res.status(500).json({ error: errorMessage }); + } + }); + // OpenRouter models list app.get("/api/openrouter/models", requireAuth, async (req, res) => { try { diff --git a/server/services/cloudinary.ts b/server/services/cloudinary.ts index 292144b..3b5431b 100644 --- a/server/services/cloudinary.ts +++ b/server/services/cloudinary.ts @@ -132,6 +132,61 @@ class CloudinaryService { return uploadResult.secure_url; } + + async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> { + const config = await storage.getAnyCloudinaryConfig(); + + if (!config) { + throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); + } + + cloudinary.config({ + cloud_name: config.cloudName, + api_key: config.apiKey, + api_secret: config.apiSecret, + secure: true, + }); + + const uploadResult = await new Promise((resolve, reject) => { + const uploadStream = cloudinary.uploader.upload_stream( + { + folder: 'social-flow/logos', + public_id: `logo-${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`, + resource_type: 'image', + }, + (error, result) => { + if (error) reject(error); + else resolve(result); + } + ); + + uploadStream.end(buffer); + }); + + return { + publicId: uploadResult.public_id, + url: uploadResult.secure_url, + }; + } + + async deleteLogo(publicId: string): Promise { + const config = await storage.getAnyCloudinaryConfig(); + + if (!config) { + throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); + } + + cloudinary.config({ + cloud_name: config.cloudName, + api_key: config.apiKey, + api_secret: config.apiSecret, + secure: true, + }); + + await cloudinary.uploader.destroy(publicId, { + resource_type: 'image', + }); + } } export const cloudinaryService = new CloudinaryService(); diff --git a/shared/schema.ts b/shared/schema.ts index 83a798b..82605ff 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -36,6 +36,7 @@ export const cloudinaryConfig = pgTable("cloudinary_config", { cloudName: text("cloud_name").notNull(), apiKey: text("api_key").notNull(), apiSecret: text("api_secret").notNull(), + logoPublicId: text("logo_public_id"), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), });