From ad97da0f50d263bd5bf22970a17939b406c1163f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Nov 2025 10:59:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Ajouter=20versions=20mobiles=20compl?= =?UTF-8?q?=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 + + +
+ +