diff --git a/client/src/App.tsx b/client/src/App.tsx index 8ce3233..29e63ca 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -7,9 +7,13 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { createResponsiveRoute } from "@/components/responsive-route"; import NotFound from "@/pages/not-found"; import Login from "@/pages/login"; -import History from "@/pages/history"; + // Create responsive routes with lazy loading for optimal performance +const History = createResponsiveRoute( + () => import("@/pages/history"), + () => import("@/pages/mobile/history") +); const Dashboard = createResponsiveRoute( () => import("@/pages/dashboard"), () => import("@/pages/mobile/dashboard") @@ -68,7 +72,7 @@ const Analytics = createResponsiveRoute( // 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 + () => import("@/pages/mobile/new-reel") // Uses same component for now ); function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) { diff --git a/client/src/pages/mobile/history.tsx b/client/src/pages/mobile/history.tsx new file mode 100644 index 0000000..4c6e4d4 --- /dev/null +++ b/client/src/pages/mobile/history.tsx @@ -0,0 +1,144 @@ +import { useQuery } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { + Calendar, CheckCircle, XCircle, Clock, + MoreHorizontal, Filter, Search +} from "lucide-react"; +import { useState } from "react"; +import Sidebar from "@/components/sidebar"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export default function MobileHistory() { + const [searchTerm, setSearchTerm] = useState(""); + + // Fetch posts history + const { data: posts } = useQuery({ + queryKey: ['/api/posts'], + }); + + const getStatusIcon = (status: string) => { + switch (status) { + case 'published': return ; + case 'failed': return ; + case 'scheduled': return ; + default: return ; + } + }; + + const getStatusLabel = (status: string) => { + switch (status) { + case 'published': return "Publié"; + case 'failed': return "Échec"; + case 'scheduled': return "Programmé"; + default: return "Brouillon"; + } + }; + + const filteredPosts = posts?.filter(post => + post.content?.toLowerCase().includes(searchTerm.toLowerCase()) + ) || []; + + return ( +
+ {/* Mobile Header */} +
+
+ + + + + + + + +

Historique

+
+ +
+ +
+ {/* Search */} +
+ + setSearchTerm(e.target.value)} + /> +
+ + {/* Timeline List */} +
+ {filteredPosts.length === 0 ? ( +
+ +

Aucune publication trouvée

+
+ ) : ( + filteredPosts.map((post) => ( + + +
+
+ +
+
+ + {getStatusIcon(post.status)} + {getStatusLabel(post.status)} + + + {post.scheduledFor ? format(new Date(post.scheduledFor), "d MMM HH:mm", { locale: fr }) : '-'} + +
+ +

+ {post.content || "Sans contenu"} +

+ +
+
+ {/* Placeholder for page icons if available in data */} +
fb
+
+ + + + + + Voir détails + Supprimer + + +
+
+
+ + + )) + )} +
+
+
+ ); +} diff --git a/client/src/pages/mobile/new-reel.tsx b/client/src/pages/mobile/new-reel.tsx new file mode 100644 index 0000000..e2d6951 --- /dev/null +++ b/client/src/pages/mobile/new-reel.tsx @@ -0,0 +1,413 @@ +import { useState, useCallback, useRef } 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, + ChevronRight, Loader2, Check, RefreshCw, ChevronLeft, + Menu +} from "lucide-react"; +import { useDropzone } from "react-dropzone"; +import Sidebar from "@/components/sidebar"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +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"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; + +interface MusicTrack { + id: string; + title: string; + artist: string; + albumName: string; + duration: number; + previewUrl: string; + downloadUrl: string; + imageUrl: string; + license: string; +} + +type Step = 'video' | 'music' | 'text' | 'publish'; + +export default function MobileNewReel() { + 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); + + const { data: pages = [] } = useQuery({ + queryKey: ['/api/pages'], + }); + + const { data: allMedia = [] } = useQuery({ + queryKey: ['/api/media'], + }); + + const videoList = allMedia.filter(m => m.type === 'video').slice(0, 12); + const [loadedTracks, setLoadedTracks] = useState([]); + + const { data: musicData, isLoading: musicLoading, refetch: refetchMusic } = useQuery<{ tracks: MusicTrack[] }>({ + queryKey: ['/api/music/search', selectedVideo?.id, musicOffset], + queryFn: async () => { + const minDuration = 10; + const maxDuration = 120; + const response = await fetch( + `/api/music/search?minDuration=${minDuration}&maxDuration=${maxDuration}&limit=10&offset=${musicOffset}` + ); + const data = await response.json(); + setLoadedTracks(data.tracks || []); + return data; + }, + enabled: currentStep === 'music' && !!selectedVideo, + }); + + const handleLoadNewMusic = () => { + setMusicOffset(prev => prev + 10); + refetchMusic(); + }; + + 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" }); + }, + onError: () => { + toast({ title: "Erreur", description: "Échec upload", variant: "destructive" }); + }, + }); + + const generateTextMutation = useMutation({ + mutationFn: async (productInfoText: string) => { + const response = await apiRequest('POST', '/api/reels/generate-text', { + productInfo: productInfoText, + }); + return response.json(); + }, + onSuccess: (data: any) => { + setGeneratedVariants(data.variants || []); + toast({ title: "Généré", description: "Variations créées" }); + }, + }); + + 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éé !" : "Attention", + description: data.success ? "Publication réussie" : "Certaines erreurs", + }); + navigate('/'); + }, + onError: (error: any) => { + toast({ title: "Erreur", description: error.message, variant: "destructive" }); + }, + }); + + const onDrop = useCallback((acceptedFiles: File[]) => { + if (acceptedFiles.length > 0 && acceptedFiles[0].type.startsWith('video/')) { + uploadMutation.mutate(acceptedFiles[0]); + } + }, []); + + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + onDrop, + accept: { "video/*": [".mp4", ".mov", ".webm"] }, + noClick: true, + noKeyboard: true, + }); + + const handleCameraCapture = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) uploadMutation.mutate(file); + }; + + const togglePlayPreview = async (track: MusicTrack) => { + if (isPlaying === track.id) { + audioRef.current?.pause(); + setIsPlaying(null); + } else { + if (audioRef.current) { + audioRef.current.src = track.previewUrl; + try { + await audioRef.current.play(); + setIsPlaying(track.id); + } catch { + setIsPlaying(null); + } + } + } + }; + + const handleCreateReel = () => { + if (!selectedVideoId || selectedPages.length === 0) { + toast({ title: "Erreur", description: "Vidéo et page requises", variant: "destructive" }); + return; + } + createReelMutation.mutate({ + videoMediaId: selectedVideoId, + musicTrackId: selectedTrack?.id, + overlayText: overlayText, + description: overlayText, + pageIds: selectedPages, + scheduledFor: scheduledDate?.toISOString(), + musicVolume: musicVolume[0] / 100, + }); + }; + + const steps: Step[] = ['video', 'music', 'text', 'publish']; + const currentStepIndex = steps.indexOf(currentStep); + + const goNext = () => { + if (currentStepIndex < steps.length - 1) setCurrentStep(steps[currentStepIndex + 1]); + }; + + const goBack = () => { + if (currentStepIndex > 0) setCurrentStep(steps[currentStepIndex - 1]); + }; + + return ( +
+ {/* Header Mobile */} +
+
+ + + + + + + + +

Nouveau Reel

+
+
+ {currentStepIndex + 1}/{steps.length} +
+
+ +
+ {/* VIDEO STEP */} + {currentStep === 'video' && ( +
+
+ + + +
+ + +
+ + {selectedVideo ? ( +
+
+ ) : ( +
+ {videoList.map((media) => ( +
setSelectedVideoId(media.id) || setSelectedVideo(media)} className={`relative aspect-[9/16] bg-black rounded-lg overflow-hidden border-2 transition-all ${selectedVideoId === media.id ? 'border-primary' : 'border-transparent'}`}> +
+ ))} +
+ )} +
+ + {selectedVideo && ( + + )} +
+ )} + + {/* MUSIC STEP */} + {currentStep === 'music' && ( +
+
+ )} + + {/* TEXT STEP */} + {currentStep === 'text' && ( +
+ + + Assistant IA + + +