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, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import { MediaThumbnail } from "@/components/media-thumbnail"; 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}
)}
); } /** Nombre de médias récents proposés à la sélection. */ const RECENT_MEDIA_COUNT = 12; /** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */ function startOfToday(): Date { const date = new Date(); date.setHours(0, 0, 0, 0); return date; } export default function NewPost() { 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); // Ce composeur gère les publications feed/story : TikTok n'accepte que des // vidéos et se pilote depuis la création de reel. const { data: allPages = [] } = useQuery({ queryKey: ['/api/pages'], }); const pages = allPages.filter(p => p.platform !== 'tiktok'); // Seuls les douze derniers médias sont proposés ici : autant ne demander // que ceux-là plutôt que toute la médiathèque. const { data: allMedia = [] } = useQuery({ queryKey: ['/api/media', { limit: RECENT_MEDIA_COUNT }], }); // Afficher seulement les 12 derniers médias triés par date décroissante // Utilisation de useMemo pour éviter de re-trier à chaque render // Création d'une copie pour ne pas muter le cache React Query 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; // Tri décroissant (plus récent en premier) }) .slice(0, RECENT_MEDIA_COUNT); }, [allMedia]); // Ne sert qu'à griser les créneaux déjà pris dans le sélecteur de date : le // passé n'a aucune influence sur une planification à venir. const { data: scheduledPosts = [] } = useQuery({ queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }], }); 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, credentials: "include", }); if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/media/upload"); throw new Error("Upload failed"); } return response.json(); }, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["/api/media"] }); // Add to selected media array if not already at max (10) 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: (error: unknown) => { toast({ title: "Erreur", description: getErrorMessage(error, "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 = () => { // Le texte est facultatif si au moins un média est présent if (!postText.trim() && selectedMedia.length === 0) { toast({ title: "Contenu requis", description: "Veuillez saisir du texte ou sélectionner un média", 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) { // Logs de diagnostic pour Android console.log('📹 Capture camera détectée:', { nom: file.name, type: file.type, taille: `${(file.size / 1024 / 1024).toFixed(2)} MB`, estVideo: file.type.startsWith('video/') }); // Correction Android : créer un nom valide si le fichier n'a pas de nom 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 }); console.log('✅ Nom de fichier corrigé:', newFileName); } // Vérification taille (50 MB max) 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", }); console.error('❌ Fichier rejeté: trop volumineux'); 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

Média Sélectionnez ou uploadez une image/vidéo
{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 Informations du produit ou message