From 50d5c77f8d2eda9b94243fee54316e127bc2d487 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Nov 2025 10:23:55 +0000 Subject: [PATCH 1/8] fix: Corriger l'alignement vertical des emojis dans les stories Facebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le problème était causé par un calcul incorrect du positionnement des emojis lorsque textBaseline est défini à 'middle'. Le code calculait la position comme si le baseline était en mode standard (alphabetic), ce qui décalait les emojis verticalement. Changement: emojiY = y - (emojiSize / 2) au lieu de y - fontSize * 0.8 Cela centre correctement l'emoji autour du point Y qui représente le centre du texte en mode 'middle'. --- server/services/emojiRenderer.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/server/services/emojiRenderer.ts b/server/services/emojiRenderer.ts index 909dc03..ae9f190 100644 --- a/server/services/emojiRenderer.ts +++ b/server/services/emojiRenderer.ts @@ -110,11 +110,12 @@ export class EmojiRenderer { try { const emojiBuffer = await this.getEmojiImage(segment.url); const emojiImage = await loadImage(emojiBuffer); - - // Draw emoji slightly above baseline to align with text + + // Draw emoji centered vertically with text + // When textBaseline is 'middle', y represents the vertical center const emojiSize = fontSize; - const emojiY = y - fontSize * 0.8; // Adjust vertical position - + const emojiY = y - (emojiSize / 2); // Center the emoji around y + ctx.drawImage(emojiImage, currentX, emojiY, emojiSize, emojiSize); currentX += emojiSize; } catch (error) { From 53054ccd87b6f38e96c668304ec0651b5379b35d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Nov 2025 10:27:01 +0000 Subject: [PATCH 2/8] =?UTF-8?q?debug:=20Ajouter=20des=20logs=20d=C3=A9tail?= =?UTF-8?q?l=C3=A9s=20pour=20diagnostiquer=20le=20probl=C3=A8me=20d'emojis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout de logs pour tracer: - Le parsing des emojis (combien détectés, URLs générées) - Le téléchargement des images (succès/échec, codes HTTP) - Le rendu final (succès/erreur avec détails) Cela permettra d'identifier si le problème vient de: - URLs Twemoji incorrectes - Échec de téléchargement depuis le CDN - Problème de chargement/décodage de l'image --- server/services/emojiRenderer.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/server/services/emojiRenderer.ts b/server/services/emojiRenderer.ts index ae9f190..648c880 100644 --- a/server/services/emojiRenderer.ts +++ b/server/services/emojiRenderer.ts @@ -19,6 +19,8 @@ export class EmojiRenderer { */ private parseTextWithEmojis(text: string): EmojiSegment[] { const emojis = parse(text); + console.log(`📝 Parse texte: "${text}" => ${emojis.length} emoji(s) trouvé(s)`); + if (emojis.length === 0) { return [{ type: 'text', content: text }]; } @@ -27,6 +29,8 @@ export class EmojiRenderer { let lastIndex = 0; for (const emoji of emojis) { + console.log(` - Emoji détecté: "${emoji.text}" => URL: ${emoji.url}`); + // Add text before emoji if (emoji.indices[0] > lastIndex) { const textContent = text.substring(lastIndex, emoji.indices[0]); @@ -64,7 +68,15 @@ export class EmojiRenderer { return this.emojiCache.get(url)!; } + console.log('🔍 Téléchargement emoji depuis:', url); const response = await fetch(url); + + if (!response.ok) { + console.error(`❌ Erreur téléchargement emoji: ${response.status} ${response.statusText}`); + throw new Error(`Failed to fetch emoji: ${response.status}`); + } + + console.log('✅ Emoji téléchargé avec succès'); const buffer = Buffer.from(await response.arrayBuffer()); this.emojiCache.set(url, buffer); return buffer; @@ -118,8 +130,10 @@ export class EmojiRenderer { ctx.drawImage(emojiImage, currentX, emojiY, emojiSize, emojiSize); currentX += emojiSize; + console.log(`✅ Emoji "${segment.content}" dessiné avec succès`); } catch (error) { - console.error('Error loading emoji:', error); + console.error(`❌ Erreur lors du chargement/rendu de l'emoji "${segment.content}":`, error); + console.error(' URL:', segment.url); // Fallback: draw text representation ctx.fillText(segment.content, currentX, y); currentX += ctx.measureText(segment.content).width; From 4506c2b7c4542e4026e996e53eb958fb9a44a2d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Nov 2025 10:35:09 +0000 Subject: [PATCH 3/8] fix: Corriger l'affichage des emojis dans les stories Facebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème: Les emojis s'affichaient comme des carrés bizarres dans les stories publiées sur Facebook (alors que la prévisualisation fonctionnait). Cause: Le téléchargement des images emoji depuis le CDN Twemoji par défaut échouait lors de la génération de l'image de story côté serveur. Solution: 1. Utilisation d'un CDN plus fiable (jsDelivr) au lieu du CDN Twemoji par défaut 2. Ajout d'une logique de retry (3 tentatives avec exponential backoff) 3. Ajout d'un timeout de 10 secondes pour éviter les blocages 4. Meilleure gestion des erreurs avec logs détaillés 5. Cache des emojis téléchargés pour améliorer les performances Les emojis devraient maintenant s'afficher correctement dans les stories Facebook publiées depuis l'application. --- server/services/emojiRenderer.ts | 77 ++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/server/services/emojiRenderer.ts b/server/services/emojiRenderer.ts index 648c880..e17cb57 100644 --- a/server/services/emojiRenderer.ts +++ b/server/services/emojiRenderer.ts @@ -19,7 +19,10 @@ export class EmojiRenderer { */ private parseTextWithEmojis(text: string): EmojiSegment[] { const emojis = parse(text); - console.log(`📝 Parse texte: "${text}" => ${emojis.length} emoji(s) trouvé(s)`); + + if (emojis.length > 0) { + console.log(`📝 Texte avec ${emojis.length} emoji(s) détecté(s)`); + } if (emojis.length === 0) { return [{ type: 'text', content: text }]; @@ -29,8 +32,6 @@ export class EmojiRenderer { let lastIndex = 0; for (const emoji of emojis) { - console.log(` - Emoji détecté: "${emoji.text}" => URL: ${emoji.url}`); - // Add text before emoji if (emoji.indices[0] > lastIndex) { const textContent = text.substring(lastIndex, emoji.indices[0]); @@ -61,25 +62,67 @@ export class EmojiRenderer { } /** - * Download and cache emoji image + * Convert default Twemoji URL to jsDelivr CDN (more reliable) + * Example: https://twemoji.maxcdn.com/v/latest/72x72/1f600.png + * Becomes: https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f600.png + */ + private convertToReliableCDN(url: string): string { + // Extract the emoji filename (e.g., "1f600.png") + const match = url.match(/\/([a-f0-9\-]+\.png)$/i); + if (!match) return url; + + const filename = match[1]; + // Use jsDelivr CDN which is more reliable + return `https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/${filename}`; + } + + /** + * Download and cache emoji image with retry logic */ private async getEmojiImage(url: string): Promise { - if (this.emojiCache.has(url)) { - return this.emojiCache.get(url)!; + // Use reliable CDN URL + const reliableUrl = this.convertToReliableCDN(url); + + if (this.emojiCache.has(reliableUrl)) { + return this.emojiCache.get(reliableUrl)!; } - console.log('🔍 Téléchargement emoji depuis:', url); - const response = await fetch(url); + console.log('🔍 Téléchargement emoji depuis:', reliableUrl); - if (!response.ok) { - console.error(`❌ Erreur téléchargement emoji: ${response.status} ${response.statusText}`); - throw new Error(`Failed to fetch emoji: ${response.status}`); + // Retry logic: try up to 3 times + let lastError: Error | null = null; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + const response = await fetch(reliableUrl, { + signal: controller.signal + }); + + clearTimeout(timeout); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + console.log('✅ Emoji téléchargé avec succès'); + const buffer = Buffer.from(await response.arrayBuffer()); + this.emojiCache.set(reliableUrl, buffer); + return buffer; + } catch (error) { + lastError = error as Error; + console.warn(`⚠️ Tentative ${attempt}/3 échouée:`, error instanceof Error ? error.message : error); + + if (attempt < 3) { + // Wait before retry (exponential backoff: 500ms, 1000ms) + await new Promise(resolve => setTimeout(resolve, attempt * 500)); + } + } } - console.log('✅ Emoji téléchargé avec succès'); - const buffer = Buffer.from(await response.arrayBuffer()); - this.emojiCache.set(url, buffer); - return buffer; + console.error(`❌ Échec téléchargement emoji après 3 tentatives:`, lastError); + throw lastError || new Error('Failed to fetch emoji'); } /** @@ -130,10 +173,8 @@ export class EmojiRenderer { ctx.drawImage(emojiImage, currentX, emojiY, emojiSize, emojiSize); currentX += emojiSize; - console.log(`✅ Emoji "${segment.content}" dessiné avec succès`); } catch (error) { - console.error(`❌ Erreur lors du chargement/rendu de l'emoji "${segment.content}":`, error); - console.error(' URL:', segment.url); + console.error(`❌ Impossible de charger l'emoji "${segment.content}":`, error instanceof Error ? error.message : error); // Fallback: draw text representation ctx.fillText(segment.content, currentX, y); currentX += ctx.measureText(segment.content).width; From ad97da0f50d263bd5bf22970a17939b406c1163f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Nov 2025 10:59:41 +0000 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20Ajouter=20versions=20mobiles=20comp?= =?UTF-8?q?l=C3=A8tes=20pour=20toutes=20les=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémentation d'un système responsive robuste avec détection automatique du type d'appareil et switch transparent entre versions desktop et mobile. ## Nouvelles fonctionnalités ### 1. Hook de détection mobile (useIsMobile) - Détection via media query (max-width: 768px) - Validation supplémentaire via user agent - Support des changements dynamiques de taille d'écran - Hook useDeviceType pour détection granulaire (mobile/tablet/desktop) ### 2. Composant ResponsiveRoute - Switch automatique entre versions desktop/mobile - Lazy loading des composants pour performances optimales - Factory function createResponsiveRoute pour création facile - Suspense avec fallback de chargement ### 3. Pages mobiles optimisées (10 pages) **Pages créées :** - dashboard.tsx - Tableau de bord - new-post.tsx - Création de posts (interface tactile optimisée) - calendar.tsx - Calendrier - media.tsx - Bibliothèque de médias - image-editor.tsx - Éditeur d'images - pages.tsx - Gestion des pages sociales - ai.tsx - Assistant IA (admin) - settings.tsx - Paramètres (admin) - users-admin.tsx - Gestion utilisateurs (admin) - sql.tsx - Administration SQL (admin) **Optimisations mobiles appliquées :** - Layout en colonne unique (au lieu de multi-colonnes) - Boutons tactiles : min-height 44-52px (Apple guidelines) - Grilles adaptées (2 colonnes au lieu de 3 pour médias) - Navigation simplifiée avec menu hamburger - Dialogs en plein écran (95vw sur mobile) - Tables converties en cards empilées - Boutons d'action en colonne au lieu de ligne - Padding et espacement optimisés pour petits écrans - Typographie réduite (text-2xl au lieu de 3xl) - Touch-manipulation sur zones interactives - Bottom fixed buttons pour actions principales - Inputs full-width avec min-height 48px ### 4. Routing automatique - App.tsx modifié pour utiliser createResponsiveRoute - Détection transparente du device - Lazy loading de toutes les routes - Aucun changement d'URL entre versions ## Performances - Code splitting automatique (lazy loading) - Seule la version nécessaire est chargée - Cache des composants chargés - Réduction de la taille du bundle initial ## Tests à effectuer 1. Rétrécir la fenêtre du navigateur < 768px 2. Utiliser les DevTools mobile 3. Tester sur un vrai appareil mobile 4. Vérifier que toutes les fonctionnalités marchent Les utilisateurs bénéficient maintenant d'une expérience optimale sur mobile ! --- client/src/App.tsx | 62 +- client/src/components/responsive-route.tsx | 49 ++ client/src/hooks/useIsMobile.ts | 72 ++ client/src/pages/mobile/ai.tsx | 42 ++ client/src/pages/mobile/calendar.tsx | 42 ++ client/src/pages/mobile/dashboard.tsx | 41 ++ client/src/pages/mobile/image-editor.tsx | 643 +++++++++++++++++ client/src/pages/mobile/media.tsx | 42 ++ client/src/pages/mobile/new-post.tsx | 790 +++++++++++++++++++++ client/src/pages/mobile/pages.tsx | 520 ++++++++++++++ client/src/pages/mobile/settings.tsx | 525 ++++++++++++++ client/src/pages/mobile/sql.tsx | 240 +++++++ client/src/pages/mobile/users-admin.tsx | 589 +++++++++++++++ 13 files changed, 3647 insertions(+), 10 deletions(-) create mode 100644 client/src/components/responsive-route.tsx create mode 100644 client/src/hooks/useIsMobile.ts create mode 100644 client/src/pages/mobile/ai.tsx create mode 100644 client/src/pages/mobile/calendar.tsx create mode 100644 client/src/pages/mobile/dashboard.tsx create mode 100644 client/src/pages/mobile/image-editor.tsx create mode 100644 client/src/pages/mobile/media.tsx create mode 100644 client/src/pages/mobile/new-post.tsx create mode 100644 client/src/pages/mobile/pages.tsx create mode 100644 client/src/pages/mobile/settings.tsx create mode 100644 client/src/pages/mobile/sql.tsx create mode 100644 client/src/pages/mobile/users-admin.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 52d1fc6..19a0b59 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,19 +4,61 @@ import { queryClient } from "./lib/queryClient"; import { QueryClientProvider, useQuery } from "@tanstack/react-query"; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { createResponsiveRoute } from "@/components/responsive-route"; import NotFound from "@/pages/not-found"; import Login from "@/pages/login"; -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"; -import Settings from "@/pages/settings"; -import SqlAdmin from "@/pages/sql"; -import UsersAdmin from "@/pages/users-admin"; + +// Create responsive routes with lazy loading for optimal performance +const Dashboard = createResponsiveRoute( + () => import("@/pages/dashboard"), + () => import("@/pages/mobile/dashboard") +); + +const NewPost = createResponsiveRoute( + () => import("@/pages/new-post"), + () => import("@/pages/mobile/new-post") +); + +const Calendar = createResponsiveRoute( + () => import("@/pages/calendar"), + () => import("@/pages/mobile/calendar") +); + +const Media = createResponsiveRoute( + () => import("@/pages/media"), + () => import("@/pages/mobile/media") +); + +const ImageEditor = createResponsiveRoute( + () => import("@/pages/image-editor"), + () => import("@/pages/mobile/image-editor") +); + +const PagesManagement = createResponsiveRoute( + () => import("@/pages/pages"), + () => import("@/pages/mobile/pages") +); + +const AI = createResponsiveRoute( + () => import("@/pages/ai"), + () => import("@/pages/mobile/ai") +); + +const Settings = createResponsiveRoute( + () => import("@/pages/settings"), + () => import("@/pages/mobile/settings") +); + +const SqlAdmin = createResponsiveRoute( + () => import("@/pages/sql"), + () => import("@/pages/mobile/sql") +); + +const UsersAdmin = createResponsiveRoute( + () => import("@/pages/users-admin"), + () => import("@/pages/mobile/users-admin") +); function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) { const [, setLocation] = useLocation(); diff --git a/client/src/components/responsive-route.tsx b/client/src/components/responsive-route.tsx new file mode 100644 index 0000000..fe5c523 --- /dev/null +++ b/client/src/components/responsive-route.tsx @@ -0,0 +1,49 @@ +import { ComponentType, Suspense, lazy } from 'react'; +import { useIsMobile } from '@/hooks/useIsMobile'; + +interface ResponsiveRouteProps { + desktop: ComponentType; + mobile: ComponentType; + fallback?: React.ReactNode; +} + +/** + * ResponsiveRoute component that automatically switches between desktop and mobile versions + * Uses lazy loading for optimal performance + */ +export function ResponsiveRoute({ desktop: Desktop, mobile: Mobile, fallback }: ResponsiveRouteProps) { + const isMobile = useIsMobile(); + + // Default loading fallback + const defaultFallback = ( +
+
+
+ ); + + return ( + + {isMobile ? : } + + ); +} + +/** + * Factory function to create lazy-loaded responsive routes + * Usage: + * const Dashboard = createResponsiveRoute( + * () => import('./pages/dashboard'), + * () => import('./pages/mobile/dashboard') + * ); + */ +export function createResponsiveRoute( + desktopImport: () => Promise<{ default: ComponentType }>, + mobileImport: () => Promise<{ default: ComponentType }> +) { + const DesktopLazy = lazy(desktopImport); + const MobileLazy = lazy(mobileImport); + + return function ResponsiveRouteWrapper() { + return ; + }; +} diff --git a/client/src/hooks/useIsMobile.ts b/client/src/hooks/useIsMobile.ts new file mode 100644 index 0000000..a68bceb --- /dev/null +++ b/client/src/hooks/useIsMobile.ts @@ -0,0 +1,72 @@ +import { useState, useEffect } from 'react'; + +/** + * Hook to detect if the user is on a mobile device + * Uses both media query and user agent for robust detection + * + * @param breakpoint - The max-width breakpoint for mobile (default: 768px) + * @returns boolean - true if mobile, false if desktop + */ +export function useIsMobile(breakpoint: number = 768): boolean { + const [isMobile, setIsMobile] = useState(() => { + // Initial detection (SSR-safe) + if (typeof window === 'undefined') return false; + + // Check media query + const mediaMatch = window.matchMedia(`(max-width: ${breakpoint}px)`).matches; + + // Check user agent for mobile devices + const userAgent = navigator.userAgent.toLowerCase(); + const mobileKeywords = ['android', 'webos', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone']; + const isUserAgentMobile = mobileKeywords.some(keyword => userAgent.includes(keyword)); + + // Return true if either media query or user agent indicates mobile + return mediaMatch || isUserAgentMobile; + }); + + useEffect(() => { + // Create media query list + const mediaQueryList = window.matchMedia(`(max-width: ${breakpoint}px)`); + + // Handler for media query changes + const handleChange = (e: MediaQueryListEvent) => { + setIsMobile(e.matches); + }; + + // Listen for changes (modern browsers) + if (mediaQueryList.addEventListener) { + mediaQueryList.addEventListener('change', handleChange); + } else { + // Fallback for older browsers + mediaQueryList.addListener(handleChange); + } + + // Cleanup + return () => { + if (mediaQueryList.removeEventListener) { + mediaQueryList.removeEventListener('change', handleChange); + } else { + mediaQueryList.removeListener(handleChange); + } + }; + }, [breakpoint]); + + return isMobile; +} + +/** + * Hook to detect device type with more granularity + * @returns object with device type information + */ +export function useDeviceType() { + const isMobile = useIsMobile(768); + const isTablet = useIsMobile(1024) && !useIsMobile(768); + const isDesktop = !useIsMobile(1024); + + return { + isMobile, + isTablet, + isDesktop, + deviceType: isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop' + } as const; +} diff --git a/client/src/pages/mobile/ai.tsx b/client/src/pages/mobile/ai.tsx new file mode 100644 index 0000000..83eab9a --- /dev/null +++ b/client/src/pages/mobile/ai.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import Sidebar from "@/components/sidebar"; +import TopBar from "@/components/topbar"; +import AiChat from "@/components/ai-chat"; + +export default function AIMobile() { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + +
+ setSidebarOpen(false)} /> +
+ +
+ setSidebarOpen(!sidebarOpen)} /> + + {/* MOBILE: Compact layout */} +
+
+

Assistant IA

+

+ Générez du contenu personnalisé avec l'intelligence artificielle +

+
+ + +
+
+
+ ); +} diff --git a/client/src/pages/mobile/calendar.tsx b/client/src/pages/mobile/calendar.tsx new file mode 100644 index 0000000..650598f --- /dev/null +++ b/client/src/pages/mobile/calendar.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import Sidebar from "@/components/sidebar"; +import TopBar from "@/components/topbar"; +import CalendarView from "@/components/calendar-view"; + +export default function CalendarMobile() { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + +
+ setSidebarOpen(false)} /> +
+ +
+ setSidebarOpen(!sidebarOpen)} /> + + {/* MOBILE: Compact layout with reduced padding */} +
+
+

Calendrier

+

+ Visualisez et gérez vos publications planifiées +

+
+ + +
+
+
+ ); +} diff --git a/client/src/pages/mobile/dashboard.tsx b/client/src/pages/mobile/dashboard.tsx new file mode 100644 index 0000000..ddc7edc --- /dev/null +++ b/client/src/pages/mobile/dashboard.tsx @@ -0,0 +1,41 @@ +import Sidebar from "@/components/sidebar"; +import TopBar from "@/components/topbar"; +import StatsCards from "@/components/stats-cards"; +import RecentPublications from "@/components/recent-publications"; +import ManagedPages from "@/components/managed-pages"; +import { useState } from "react"; + +export default function DashboardMobile() { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + +
+ setSidebarOpen(false)} /> +
+ +
+ setSidebarOpen(!sidebarOpen)} /> + + {/* MOBILE: Reduced padding, single column layout with better spacing */} +
+ + + + + +
+
+
+ ); +} diff --git a/client/src/pages/mobile/image-editor.tsx b/client/src/pages/mobile/image-editor.tsx new file mode 100644 index 0000000..2f18fea --- /dev/null +++ b/client/src/pages/mobile/image-editor.tsx @@ -0,0 +1,643 @@ +import { useState, useEffect, useRef } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +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, Image as ImageIcon, X } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; + +interface Ribbon { + enabled: boolean; + text: "Promo" | "Nouveauté" | "Arrivage" | "Stock limité"; + 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; +} + +interface Logo { + enabled: boolean; + opacity: number; + position: "north_east" | "south_east" | "north_west" | "south_west" | "center"; + size: "small" | "medium" | "large"; +} + +export default function ImageEditorMobile() { + 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 + }); + + const [logo, setLogo] = useState({ + enabled: false, + opacity: 60, + position: "south_east", + size: "medium" + }); + + const [previewUrl, setPreviewUrl] = useState(""); + + const { data: mediaList } = useQuery({ + queryKey: ["/api/media"], + }); + + const { data: cloudinaryConfig } = useQuery({ + queryKey: ['/api/cloudinary/config'], + }); + + const previewRef = useRef(null); + const { toast } = useToast(); + + const saveImageMutation = useMutation({ + mutationFn: async () => { + if (!selectedMedia) { + throw new Error("Aucune image sélectionnée"); + } + + const payload = { + imageUrl: selectedMedia.originalUrl, + ribbon: ribbon.enabled ? ribbon : undefined, + priceBadge: priceBadge.enabled && priceBadge.price ? priceBadge : undefined, + logo: logo.enabled ? logo : undefined, + }; + + const response = await apiRequest('POST', '/api/media/apply-overlays', payload); + return response; + }, + 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" + }); + } + }); + + useEffect(() => { + if (!selectedMedia) { + setPreviewUrl(""); + return; + } + + let url = selectedMedia.originalUrl; + + 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, ''); + + const transformations: string[] = []; + transformations.push('w_800,h_800,c_fit'); + + const transformedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${transformations.join('/')}/${imagePath}`; + setPreviewUrl(transformedUrl); + + }, [selectedMedia, ribbon, priceBadge, logo]); + + return ( +
+ + + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + +
+ setSidebarOpen(false)} /> +
+ +
+ setSidebarOpen(!sidebarOpen)} /> + + {/* MOBILE: Single column layout */} +
+
+

+ + Éditeur d'images +

+

+ Ajoutez des rubans, badges prix et filtres +

+
+ + {/* Preview Card - Top on mobile */} + {selectedMedia && ( + + + Aperçu + + +
+
+
+ Aperçu + + {ribbon.enabled && ribbon.text && ( +
+
+
+ {ribbon.text === "Stock limité" ? ( + <> + Stock +
+ limité + + ) : ribbon.text} +
+
+ )} + + {priceBadge.enabled && priceBadge.price && ( +
+ {priceBadge.price} € +
+ )} + + {logo.enabled && cloudinaryConfig && (cloudinaryConfig as any).logoPublicId && ( + Logo + )} +
+ + +
+ + +
+ + + )} + + {/* 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 })} + className="w-5 h-5" + data-testid="checkbox-ribbon" + /> + +
+ + {ribbon.enabled && ( + <> +
+ + setRibbon({ ...ribbon, text: value as any })} + className="space-y-2" + > + {["Promo", "Nouveauté", "Arrivage", "Stock limité"].map((text) => ( +
+ + +
+ ))} +
+
+ +
+ + setRibbon({ ...ribbon, color: value as any })} + className="space-y-2" + > +
+ + +
+
+ + +
+
+
+ +
+ + +
+ + )} +
+
+ + {/* Price Badge */} + + + Badge prix + + +
+ setPriceBadge({ ...priceBadge, enabled: checked as boolean })} + className="w-5 h-5" + data-testid="checkbox-price" + /> + +
+ + {priceBadge.enabled && ( + <> +
+ + setPriceBadge({ ...priceBadge, price: e.target.value })} + className="min-h-[48px]" + data-testid="input-price" + /> +
+ +
+ + setPriceBadge({ ...priceBadge, color: value as any })} + className="space-y-2" + > +
+ + +
+
+ + +
+
+
+ +
+ + +
+ +
+
+ + {priceBadge.size}px +
+ setPriceBadge({ ...priceBadge, size: value[0] })} + min={12} + max={70} + step={1} + className="touch-manipulation" + data-testid="slider-price-size" + /> +
+ + )} +
+
+ + {/* Logo */} + + + Logo + + +
+ setLogo({ ...logo, enabled: checked as boolean })} + className="w-5 h-5" + data-testid="checkbox-logo-enabled" + /> + +
+ + {logo.enabled && ( + <> +
+ + +
+ +
+ + +
+ +
+
+ + {logo.opacity}% +
+ setLogo({ ...logo, opacity: value[0] })} + min={0} + max={100} + step={5} + className="touch-manipulation" + data-testid="slider-logo-opacity" + /> +
+ + )} +
+
+
+
+
+ ); +} diff --git a/client/src/pages/mobile/media.tsx b/client/src/pages/mobile/media.tsx new file mode 100644 index 0000000..3719787 --- /dev/null +++ b/client/src/pages/mobile/media.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import Sidebar from "@/components/sidebar"; +import TopBar from "@/components/topbar"; +import MediaUpload from "@/components/media-upload"; + +export default function MediaMobile() { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + +
+ setSidebarOpen(false)} /> +
+ +
+ setSidebarOpen(!sidebarOpen)} /> + + {/* MOBILE: Reduced padding for better space usage */} +
+
+

Médiathèque

+

+ Gérez vos images et vidéos pour vos publications +

+
+ + +
+
+
+ ); +} diff --git a/client/src/pages/mobile/new-post.tsx b/client/src/pages/mobile/new-post.tsx new file mode 100644 index 0000000..5b298b1 --- /dev/null +++ b/client/src/pages/mobile/new-post.tsx @@ -0,0 +1,790 @@ +import { useState, useCallback, useRef, useMemo } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { useLocation } from "wouter"; +import { Send, Sparkles, Image as ImageIcon, Calendar, Upload, Camera, GripVertical, Loader2 } from "lucide-react"; +import { useDropzone } from "react-dropzone"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + rectSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import Sidebar from "@/components/sidebar"; +import TopBar from "@/components/topbar"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; +import { PreviewModal } from "@/components/preview-modal"; +import { DateTimePicker } from "@/components/datetime-picker"; + +function SortableMediaItem({ + media, + index, + isSelected, + onToggle +}: { + media: Media; + index: number; + isSelected: boolean; + onToggle: () => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id: media.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const isVideo = media.type === 'video'; + + return ( +
+ + {isSelected && ( + <> +
+ {index + 1} +
+
+ +
+ + )} +
+ ); +} + +export default function NewPostMobile() { + const [sidebarOpen, setSidebarOpen] = useState(false); + const [, navigate] = useLocation(); + const { toast } = useToast(); + const cameraInputRef = useRef(null); + + const [productInfo, setProductInfo] = useState(''); + const [selectedMedia, setSelectedMedia] = useState([]); + const [selectedPages, setSelectedPages] = useState([]); + const [scheduledDate, setScheduledDate] = useState(undefined); + const [generatedVariants, setGeneratedVariants] = useState([]); + const [postText, setPostText] = useState(''); + const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed'); + const [previewModalOpen, setPreviewModalOpen] = useState(false); + + const { data: pages = [] } = useQuery({ + queryKey: ['/api/pages'], + }); + + const { data: allMedia = [] } = useQuery({ + queryKey: ['/api/media'], + }); + + const mediaList = useMemo(() => { + return [...allMedia] + .sort((a, b) => { + const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return dateB - dateA; + }) + .slice(0, 12); + }, [allMedia]); + + const { data: scheduledPosts = [] } = useQuery({ + queryKey: ['/api/scheduled-posts'], + }); + + const uploadMutation = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append("file", file); + const response = await fetch("/api/media/upload", { + method: "POST", + body: formData, + }); + if (!response.ok) throw new Error("Upload failed"); + return response.json(); + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["/api/media"] }); + setSelectedMedia(prev => { + if (prev.length >= 10) { + toast({ + title: "Limite atteinte", + description: "Maximum 10 photos par publication", + variant: "destructive", + }); + return prev; + } + return [...prev, data.id]; + }); + toast({ + title: "Succès", + description: "Photo téléchargée avec succès", + }); + }, + onError: () => { + toast({ + title: "Erreur", + description: "Impossible de télécharger la photo", + variant: "destructive", + }); + }, + }); + + const onDrop = useCallback((acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + uploadMutation.mutate(acceptedFiles[0]); + } + }, []); + + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + onDrop, + accept: { + "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"], + "video/*": [".mp4", ".mov", ".webm", ".3gp", ".3gpp", ".mkv", ".avi"], + }, + maxSize: 52428800, + noClick: true, + noKeyboard: true, + }); + + const generateTextMutation = useMutation({ + mutationFn: async (productInfoText: string) => { + const productInfo = { + name: productInfoText.match(/Produit:\s*(.+?)(?:\n|$)/i)?.[1] || + productInfoText.match(/Nom:\s*(.+?)(?:\n|$)/i)?.[1] || + productInfoText, + price: productInfoText.match(/Prix:\s*(.+?)(?:\n|$)/i)?.[1] || "", + description: productInfoText.match(/Description:\s*(.+?)(?:\n|$)/i)?.[1] || "", + features: productInfoText.match(/Caractéristiques:\s*(.+?)(?:\n|$)/i)?.[1]?.split(",") || [], + }; + const response = await apiRequest('POST', '/api/ai/generate', productInfo); + return response.json(); + }, + onSuccess: (data: any) => { + const variants = data.variants || []; + setGeneratedVariants(variants); + toast({ + title: "Texte généré", + description: `${variants.length} variations créées`, + }); + queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] }); + }, + onError: () => { + toast({ + title: "Erreur", + description: "Impossible de générer le texte", + variant: "destructive", + }); + }, + }); + + const createPostMutation = useMutation({ + mutationFn: (data: any) => + apiRequest('POST', '/api/posts', data), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' }); + toast({ + title: "Publication créée", + description: "La publication a été créée avec succès", + }); + navigate('/'); + }, + onError: () => { + toast({ + title: "Erreur", + description: "Impossible de créer la publication", + variant: "destructive", + }); + }, + }); + + const handleGenerateText = () => { + if (!productInfo.trim()) { + toast({ + title: "Information manquante", + description: "Veuillez saisir les informations du produit", + variant: "destructive", + }); + return; + } + generateTextMutation.mutate(productInfo); + }; + + const handleCreatePost = () => { + if (!postText.trim()) { + toast({ + title: "Contenu requis", + description: "Veuillez saisir le contenu de la publication", + variant: "destructive", + }); + return; + } + + if (selectedPages.length === 0) { + toast({ + title: "Page requise", + description: "Veuillez sélectionner au moins une page", + variant: "destructive", + }); + return; + } + + if ((postType === 'story' || postType === 'both') && selectedMedia.length === 0) { + toast({ + title: "Média requis", + description: "Les stories nécessitent au moins une image ou vidéo", + variant: "destructive", + }); + return; + } + + createPostMutation.mutate({ + content: postText, + scheduledFor: scheduledDate ? scheduledDate.toISOString() : undefined, + mediaIds: selectedMedia.length > 0 ? selectedMedia : undefined, + pageIds: selectedPages, + postType, + }); + }; + + const handleUseVariant = (text: string) => { + setPostText(text); + toast({ + title: "Texte sélectionné", + description: "Le texte a été ajouté à votre publication", + }); + }; + + const handleCameraCapture = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + let fileToUpload = file; + if (!file.name || file.name === 'blob' || file.name === '') { + const extension = file.type.split('/')[1] || 'jpg'; + const newFileName = `camera-${Date.now()}.${extension}`; + fileToUpload = new File([file], newFileName, { type: file.type }); + } + + if (file.size > 52428800) { + toast({ + title: "Fichier trop volumineux", + description: `La taille maximale est de 50 MB. Votre fichier fait ${(file.size / 1024 / 1024).toFixed(2)} MB`, + variant: "destructive", + }); + e.target.value = ''; + return; + } + + uploadMutation.mutate(fileToUpload); + e.target.value = ''; + } + }; + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setSelectedMedia((items) => { + const oldIndex = items.indexOf(active.id as string); + const newIndex = items.indexOf(over.id as string); + return arrayMove(items, oldIndex, newIndex); + }); + } + }; + + return ( +
+ {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + +
+ setSidebarOpen(false)} /> +
+ +
+ setSidebarOpen(!sidebarOpen)} /> + +
+
+

Nouvelle publication

+

+ Créez et planifiez une nouvelle publication +

+
+ + {/* MOBILE: Single column layout */} +
+ {/* Média Card */} + + +
+
+ Média + Sélectionnez ou uploadez +
+
+ + +
+
+
+ + +
+ + {mediaList.length === 0 ? ( +
+ +

+ {isDragActive ? "Déposez votre photo ici" : "Aucun média disponible"} +

+
+ + +
+
+ ) : ( + <> + {selectedMedia.length > 0 && ( +
+
+ Photos sélectionnées ({selectedMedia.length}/10) +
+ + +
+ {selectedMedia.map((mediaId, index) => { + const media = mediaList.find(m => m.id === mediaId); + if (!media) return null; + + return ( + { + setSelectedMedia(prev => prev.filter(id => id !== media.id)); + }} + /> + ); + })} +
+
+
+
+ )} +
+ Toutes les photos ({mediaList.length}) +
+
+
+ {mediaList.map((media) => { + const isSelected = selectedMedia.includes(media.id); + const isVideo = media.type === 'video'; + + return ( + + ); + })} +
+
+ + )} +
+
+
+ + {/* Contenu Card */} + + + Contenu + Informations du produit ou message + + +
+ +