diff --git a/client/src/App.tsx b/client/src/App.tsx index 9e3ed4d..8ce3233 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -65,6 +65,12 @@ const Analytics = createResponsiveRoute( () => import("@/pages/mobile/analytics") ); +// NewReel uses desktop version for now (mobile can be added later) +const NewReel = createResponsiveRoute( + () => import("@/pages/new-reel"), + () => import("@/pages/new-reel") // Uses same component for now +); + function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) { const [, setLocation] = useLocation(); @@ -114,6 +120,7 @@ function Router() { {() => } {() => } + {() => } {() => } {() => } {() => } diff --git a/client/src/components/sidebar.tsx b/client/src/components/sidebar.tsx index e73049b..c63103c 100644 --- a/client/src/components/sidebar.tsx +++ b/client/src/components/sidebar.tsx @@ -1,4 +1,4 @@ -import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, Wand2, BarChart3 } from "lucide-react"; +import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, Wand2, BarChart3, Video } from "lucide-react"; import { Link, useLocation } from "wouter"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiRequest, queryClient } from "@/lib/queryClient"; @@ -61,6 +61,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) { const navItems = [ { icon: Home, label: "Tableau de bord", href: "/", badge: null }, { icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null }, + { icon: Video, label: "Nouveau Reel", href: "/reel", badge: "New" }, { icon: Calendar, label: "Calendrier", href: "/calendar", badge: null }, { icon: Images, label: "Médiathèque", href: "/media", badge: null }, { icon: Wand2, label: "Éditeur d'images", href: "/image-editor", badge: null }, diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx new file mode 100644 index 0000000..3fe5f9d --- /dev/null +++ b/client/src/pages/new-reel.tsx @@ -0,0 +1,832 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { useLocation } from "wouter"; +import { + Send, Sparkles, Video, Music, Type, Calendar, + Upload, Camera, Play, Pause, Volume2, VolumeX, + ChevronRight, Loader2, Check +} from "lucide-react"; +import { useDropzone } from "react-dropzone"; +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 { Slider } from "@/components/ui/slider"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import type { SocialPage, Media } from "@shared/schema"; +import { DateTimePicker } from "@/components/datetime-picker"; + +interface MusicTrack { + id: string; + title: string; + artist: string; + albumName: string; + duration: number; + previewUrl: string; + downloadUrl: string; + imageUrl: string; + license: string; +} + +// Étapes du workflow +type Step = 'video' | 'music' | 'text' | 'publish'; + +export default function NewReel() { + const [sidebarOpen, setSidebarOpen] = useState(false); + const [, navigate] = useLocation(); + const { toast } = useToast(); + const cameraInputRef = useRef(null); + const audioRef = useRef(null); + + // État du workflow + const [currentStep, setCurrentStep] = useState('video'); + + // État des données + const [selectedVideoId, setSelectedVideoId] = useState(null); + const [selectedVideo, setSelectedVideo] = useState(null); + const [selectedTrack, setSelectedTrack] = useState(null); + const [overlayText, setOverlayText] = useState(''); + const [productInfo, setProductInfo] = useState(''); + const [generatedVariants, setGeneratedVariants] = useState([]); + const [selectedPages, setSelectedPages] = useState([]); + const [scheduledDate, setScheduledDate] = useState(undefined); + const [musicVolume, setMusicVolume] = useState([25]); + + // État audio preview + const [isPlaying, setIsPlaying] = useState(null); + const [musicOffset, setMusicOffset] = useState(0); + + // Récupérer les pages disponibles + const { data: pages = [] } = useQuery({ + queryKey: ['/api/pages'], + }); + + // Récupérer les vidéos disponibles + const { data: allMedia = [] } = useQuery({ + queryKey: ['/api/media'], + }); + + const videoList = allMedia.filter(m => m.type === 'video').slice(0, 12); + + // Recherche de musiques par durée + const { data: musicData, isLoading: musicLoading } = useQuery<{ tracks: MusicTrack[] }>({ + queryKey: ['/api/music/search', selectedVideo?.id], + queryFn: async () => { + // Estimer la durée vidéo (on prend une marge de ±10 secondes) + const minDuration = 10; + const maxDuration = 120; // On prend toutes les musiques courtes + const response = await fetch( + `/api/music/search?minDuration=${minDuration}&maxDuration=${maxDuration}&limit=10` + ); + return response.json(); + }, + enabled: currentStep === 'music' && !!selectedVideo, + }); + + // Mutation pour voir plus de musiques + const loadMoreMusicMutation = useMutation({ + mutationFn: async (offset: number) => { + const response = await fetch( + `/api/music/more?minDuration=10&maxDuration=120&limit=10&offset=${offset}` + ); + return response.json(); + }, + onSuccess: (data) => { + setMusicOffset(prev => prev + 10); + // Vous pourriez ajouter les nouveaux tracks à une liste + }, + }); + + // Upload vidéo + 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"] }); + setSelectedVideoId(data.id); + setSelectedVideo(data); + toast({ + title: "Succès", + description: "Vidéo téléchargée avec succès", + }); + }, + onError: () => { + toast({ + title: "Erreur", + description: "Impossible de télécharger la vidéo", + variant: "destructive", + }); + }, + }); + + // Génération de texte IA + const generateTextMutation = useMutation({ + mutationFn: async (productInfoText: string) => { + const response = await apiRequest('POST', '/api/reels/generate-text', { + productInfo: productInfoText, + }); + return response.json(); + }, + onSuccess: (data: any) => { + const variants = data.variants || []; + setGeneratedVariants(variants); + toast({ + title: "Texte généré", + description: `${variants.length} variations créées`, + }); + }, + onError: () => { + toast({ + title: "Erreur", + description: "Impossible de générer le texte", + variant: "destructive", + }); + }, + }); + + // Création du Reel + const createReelMutation = useMutation({ + mutationFn: async (data: any) => { + const response = await apiRequest('POST', '/api/reels', data); + return response.json(); + }, + onSuccess: async (data) => { + await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' }); + toast({ + title: data.success ? "Reel créé !" : "Reel créé avec avertissements", + description: data.success + ? "Votre Reel a été publié avec succès" + : "Certaines pages ont échoué", + }); + navigate('/'); + }, + onError: (error: any) => { + toast({ + title: "Erreur", + description: error.message || "Impossible de créer le Reel", + variant: "destructive", + }); + }, + }); + + const onDrop = useCallback((acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + const file = acceptedFiles[0]; + if (file.type.startsWith('video/')) { + uploadMutation.mutate(file); + } else { + toast({ + title: "Format invalide", + description: "Veuillez sélectionner un fichier vidéo", + variant: "destructive", + }); + } + } + }, []); + + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + onDrop, + accept: { + "video/*": [".mp4", ".mov", ".webm", ".3gp", ".3gpp", ".mkv", ".avi"], + }, + maxSize: 52428800, + noClick: true, + noKeyboard: true, + }); + + const handleCameraCapture = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + uploadMutation.mutate(file); + e.target.value = ''; + } + }; + + const handleSelectVideo = (media: Media) => { + setSelectedVideoId(media.id); + setSelectedVideo(media); + setCurrentStep('music'); + }; + + const handleSelectTrack = (track: MusicTrack) => { + setSelectedTrack(track); + // Arrêter la preview si en cours + if (audioRef.current) { + audioRef.current.pause(); + } + setIsPlaying(null); + }; + + const togglePlayPreview = (track: MusicTrack) => { + if (isPlaying === track.id) { + audioRef.current?.pause(); + setIsPlaying(null); + } else { + if (audioRef.current) { + audioRef.current.src = track.previewUrl; + audioRef.current.play(); + setIsPlaying(track.id); + } + } + }; + + const handleGenerateText = () => { + if (!productInfo.trim()) { + toast({ + title: "Information manquante", + description: "Veuillez saisir les informations du produit", + variant: "destructive", + }); + return; + } + generateTextMutation.mutate(productInfo); + }; + + const handleUseVariant = (text: string) => { + setOverlayText(text); + toast({ + title: "Texte sélectionné", + description: "Le texte a été ajouté à votre Reel", + }); + }; + + const handleCreateReel = () => { + if (!selectedVideoId) { + toast({ + title: "Vidéo requise", + description: "Veuillez sélectionner une vidéo", + variant: "destructive", + }); + return; + } + + if (selectedPages.length === 0) { + toast({ + title: "Page requise", + description: "Veuillez sélectionner au moins une page", + variant: "destructive", + }); + return; + } + + createReelMutation.mutate({ + videoMediaId: selectedVideoId, + musicTrackId: selectedTrack?.id, + overlayText: overlayText, + description: overlayText, + pageIds: selectedPages, + scheduledFor: scheduledDate?.toISOString(), + musicVolume: musicVolume[0] / 100, + }); + }; + + const canProceedToMusic = !!selectedVideo; + const canProceedToText = canProceedToMusic; // Musique optionnelle + const canProceedToPublish = canProceedToText; + const canPublish = selectedPages.length > 0 && !!selectedVideo; + + const formatDuration = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + return ( +
+ {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + +
+ setSidebarOpen(false)} /> +
+ +
+ setSidebarOpen(!sidebarOpen)} /> + +
+
+

+

+

+ Créez un Reel Facebook avec musique et texte overlay +

+
+ + {/* Stepper */} +
+ {(['video', 'music', 'text', 'publish'] as Step[]).map((step, index) => ( +
+ + {index < 3 && } +
+ ))} +
+ + {/* Contenu par étape */} +
+ {/* Colonne principale */} +
+ + {/* ÉTAPE 1: Vidéo */} + {currentStep === 'video' && ( + + + + + + Choisissez une vidéo existante ou capturez-en une nouvelle + + + + + +
+ + +
+ + +
+ + {videoList.length === 0 ? ( +
+
+ ) : ( +
+ {videoList.map((media) => ( + + ))} +
+ )} +
+ + {selectedVideo && ( +
+ +
+ )} +
+
+ )} + + {/* ÉTAPE 2: Musique */} + {currentStep === 'music' && ( + + + + + Choisissez une Musique + + + Sélectionnez une musique libre de droits (optionnel) + + + + + + )} + + {/* ÉTAPE 3: Texte */} + {currentStep === 'text' && ( + <> + + + + + Générer avec l'IA + + + Décrivez votre produit pour générer des textes + + + +