diff --git a/client/src/App.tsx b/client/src/App.tsx index 2d375b4..69356b1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -81,6 +81,12 @@ const NewReel = createResponsiveRoute( () => import("@/pages/mobile/new-reel") // Uses same component for now ); +// Reel déjà monté : même page responsive sur mobile et desktop +const ScheduleReel = createResponsiveRoute( + () => import("@/pages/schedule-reel"), + () => import("@/pages/schedule-reel") +); + const RemotionVideo = createResponsiveRoute( () => import("@/pages/remotion-video"), () => import("@/pages/mobile/remotion-video") @@ -136,6 +142,7 @@ function Router() { {() => } {() => } {() => } + {() => } {() => } {() => } {() => } diff --git a/client/src/components/sidebar.tsx b/client/src/components/sidebar.tsx index 729e568..7cf0735 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, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react"; +import { Home, PlusCircle, CalendarClock, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react"; import { Link, useLocation } from "wouter"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiRequest, AUTH_MUTATION, queryClient } from "@/lib/queryClient"; @@ -71,6 +71,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) { { 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: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule", badge: "New" }, { icon: Video, label: "Générateur Vidéo", href: "/remotion", badge: "New" }, { icon: Calendar, label: "Calendrier", href: "/calendar", badge: null }, { icon: Images, label: "Médiathèque", href: "/media", badge: null }, diff --git a/client/src/pages/mobile/dashboard.tsx b/client/src/pages/mobile/dashboard.tsx index 9059e0c..c232396 100644 --- a/client/src/pages/mobile/dashboard.tsx +++ b/client/src/pages/mobile/dashboard.tsx @@ -13,7 +13,8 @@ import { Users, ChevronRight, Sparkles, - Film + Film, + CalendarClock } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -29,6 +30,7 @@ export default function DashboardMobile() { ]; const secondaryMenu = [ + { icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule" }, { icon: Film, label: "Générateur Vidéo", href: "/remotion" }, { icon: Images, label: "Médiathèque", href: "/media" }, { icon: Wand2, label: "Éditeur d'images", href: "/image-editor" }, diff --git a/client/src/pages/schedule-reel.tsx b/client/src/pages/schedule-reel.tsx new file mode 100644 index 0000000..ad68f64 --- /dev/null +++ b/client/src/pages/schedule-reel.tsx @@ -0,0 +1,296 @@ +import { useState, useCallback } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { useLocation } from "wouter"; +import { CalendarClock, Upload, Loader2, Check, Video } 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 { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; +import type { SocialPage, Media } from "@shared/schema"; +import { SiFacebook, SiTiktok } from "react-icons/si"; +import { MediaThumbnail } from "@/components/media-thumbnail"; +import { DateTimePicker } from "@/components/datetime-picker"; + +/** + * Programmation d'un Reel déjà monté : pas de rendu, la vidéo envoyée est + * publiée telle quelle par le planificateur à la date choisie. + */ +export default function ScheduleReel() { + const [sidebarOpen, setSidebarOpen] = useState(false); + const [, navigate] = useLocation(); + const { toast } = useToast(); + + const [selectedVideo, setSelectedVideo] = useState(null); + const [description, setDescription] = useState(""); + const [selectedPages, setSelectedPages] = useState([]); + const [scheduledDate, setScheduledDate] = useState(undefined); + + const { data: pages = [] } = useQuery({ queryKey: ["/api/pages"] }); + const { data: allMedia = [] } = useQuery({ queryKey: ["/api/media"] }); + + // Destinations acceptant un Reel + const facebookPages = pages.filter((p) => p.platform === "facebook"); + const tiktokAccounts = pages.filter((p) => p.platform === "tiktok"); + const videoList = allMedia.filter((m) => m.type === "video").slice(0, 12); + + 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() as Promise; + }, + onSuccess: (media) => { + queryClient.invalidateQueries({ queryKey: ["/api/media"] }); + setSelectedVideo(media); + toast({ title: "Vidéo envoyée", description: media.fileName }); + }, + onError: () => { + toast({ title: "Erreur", description: "Impossible d'envoyer la vidéo", variant: "destructive" }); + }, + }); + + const onDrop = useCallback((files: File[]) => { + if (files[0]) uploadMutation.mutate(files[0]); + }, [uploadMutation]); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { "video/*": [".mp4", ".mov", ".webm"] }, + maxFiles: 1, + disabled: uploadMutation.isPending, + }); + + const scheduleMutation = useMutation({ + mutationFn: async () => { + const response = await apiRequest("POST", "/api/posts", { + content: description, + postType: "reel", + mediaIds: [selectedVideo!.id], + pageIds: selectedPages, + scheduledFor: scheduledDate!.toISOString(), + }); + return response.json(); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["/api/scheduled-posts"], refetchType: "all" }); + await queryClient.invalidateQueries({ queryKey: ["/api/posts"] }); + toast({ title: "Reel programmé", description: "Il sera publié à la date choisie." }); + navigate("/calendar"); + }, + onError: (error: unknown) => { + toast({ + title: "Erreur", + description: getErrorMessage(error, "Impossible de programmer le Reel"), + variant: "destructive", + }); + }, + }); + + const isPast = !!scheduledDate && scheduledDate.getTime() <= Date.now(); + const canSchedule = !!selectedVideo && selectedPages.length > 0 && !!scheduledDate && !isPast; + + const togglePage = (id: string, checked: boolean) => { + setSelectedPages((prev) => (checked ? [...prev, id] : prev.filter((p) => p !== id))); + }; + + const renderTargetCheckbox = (page: SocialPage) => ( +
+ togglePage(page.id, checked === true)} + /> + +
+ ); + + return ( +
+ {sidebarOpen && ( +
setSidebarOpen(false)} /> + )} + +
+ setSidebarOpen(false)} /> +
+ +
+ setSidebarOpen(!sidebarOpen)} /> + +
+
+

+ + Programmer un Reel +

+

+ Publiez une vidéo déjà montée, telle quelle, à la date et l'heure choisies +

+
+ + {/* 1. Vidéo */} + + + + + Format vertical 9:16 recommandé + + +
+ + {uploadMutation.isPending ? ( +
+ Envoi en cours... +
+ ) : ( +
+ +

Déposez votre Reel ici ou cliquez pour choisir

+

MP4, MOV ou WebM

+
+ )} +
+ + {videoList.length > 0 && ( +
+ +
+ {videoList.map((media) => { + const selected = selectedVideo?.id === media.id; + return ( + + ); + })} +
+
+ )} + + {selectedVideo && ( +

+ Sélectionnée : {selectedVideo.fileName} +

+ )} +
+
+ + {/* 2. Description */} + + + Description + + +